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.
@@ -17,6 +17,8 @@ import { HyperliquidAdapter } from "./exchanges/hyperliquid.js";
17
17
  import { LighterAdapter } from "./exchanges/lighter.js";
18
18
  import { fetchPacificaPrices, fetchHyperliquidMeta, fetchLighterOrderBookDetails, pingPacifica, pingHyperliquid, pingLighter, } from "./shared-api.js";
19
19
  import { fetchAllFundingRates } from "./funding-rates.js";
20
+ import { loadArbState } from "./arb/state.js";
21
+ import { validateTrade } from "./trade-validator.js";
20
22
  // ── Adapter cache & factory ──
21
23
  const adapters = new Map();
22
24
  async function getOrCreateAdapter(exchange) {
@@ -59,7 +61,7 @@ function err(error, meta) {
59
61
  // ── MCP Server ──
60
62
  const _require = createRequire(import.meta.url);
61
63
  const _pkg = _require("../package.json");
62
- const server = new McpServer({ name: "perp-cli", version: _pkg.version }, { capabilities: { tools: {}, resources: {} } });
64
+ const server = new McpServer({ name: "perp-cli", version: _pkg.version }, { capabilities: { tools: {}, resources: {}, prompts: {} } });
63
65
  // ============================================================
64
66
  // Market Data tools (read-only, no private key needed)
65
67
  // ============================================================
@@ -1147,6 +1149,447 @@ server.resource("cli_schema", "perp://schema", { mimeType: "application/json", d
1147
1149
  };
1148
1150
  return { contents: [{ uri: "perp://schema", text: JSON.stringify(schema, null, 2), mimeType: "application/json" }] };
1149
1151
  });
1152
+ // ============================================================
1153
+ // Trade execution tools (dry-run + confirm safety model)
1154
+ // ============================================================
1155
+ server.tool("trade_preview", "Preview a trade WITHOUT executing. Returns estimated fill price, fees, margin impact, and risk checks. ALWAYS call this before trade_execute and show the result to the user for confirmation.", {
1156
+ exchange: z.string().describe("Exchange: pacifica, hyperliquid, or lighter"),
1157
+ symbol: z.string().describe("Trading symbol (e.g., BTC, ETH, SOL)"),
1158
+ side: z.enum(["buy", "sell"]).describe("Order side"),
1159
+ size: z.string().describe("Order size (base currency units, e.g., '0.1' for 0.1 BTC)"),
1160
+ orderType: z.enum(["market", "limit"]).default("market").describe("Order type"),
1161
+ price: z.string().optional().describe("Limit price (required for limit orders)"),
1162
+ }, async ({ exchange, symbol, side, size, orderType, price }) => {
1163
+ try {
1164
+ const adapter = await getOrCreateAdapter(exchange);
1165
+ const [balance, positions, orderbook] = await Promise.all([
1166
+ adapter.getBalance(),
1167
+ adapter.getPositions(),
1168
+ adapter.getOrderbook(symbol),
1169
+ ]);
1170
+ // Estimate fill price from orderbook
1171
+ const bestBid = orderbook.bids[0]?.[0];
1172
+ const bestAsk = orderbook.asks[0]?.[0];
1173
+ const estPrice = orderType === "limit" && price
1174
+ ? price
1175
+ : side === "buy" ? bestAsk : bestBid;
1176
+ const notional = estPrice ? Number(estPrice) * Number(size) : 0;
1177
+ const existingPos = positions.find(p => p.symbol.toUpperCase().includes(symbol.toUpperCase()));
1178
+ // Run validation
1179
+ let validation;
1180
+ try {
1181
+ validation = await validateTrade(adapter, { symbol, side, size: Number(size), leverage: existingPos?.leverage ?? 1 });
1182
+ }
1183
+ catch {
1184
+ validation = { valid: true, checks: [], warnings: ["Validation unavailable — proceed with caution"] };
1185
+ }
1186
+ const preview = {
1187
+ order: { exchange, symbol, side, size, orderType, price: price ?? estPrice },
1188
+ estimate: {
1189
+ fillPrice: estPrice ?? "unknown",
1190
+ notionalUsd: notional.toFixed(2),
1191
+ spread: bestBid && bestAsk ? ((Number(bestAsk) - Number(bestBid)) / Number(bestBid) * 100).toFixed(4) + "%" : "unknown",
1192
+ },
1193
+ account: {
1194
+ equity: balance.equity,
1195
+ available: balance.available,
1196
+ marginUsed: balance.marginUsed,
1197
+ marginAfter: (Number(balance.marginUsed) + notional / (existingPos?.leverage ?? 1)).toFixed(2),
1198
+ },
1199
+ existingPosition: existingPos ? {
1200
+ side: existingPos.side,
1201
+ size: existingPos.size,
1202
+ entryPrice: existingPos.entryPrice,
1203
+ unrealizedPnl: existingPos.unrealizedPnl,
1204
+ } : null,
1205
+ validation,
1206
+ warning: "This is a PREVIEW only. Call trade_execute to place the order after user confirms.",
1207
+ };
1208
+ return { content: [{ type: "text", text: ok(preview, { exchange, type: "preview" }) }] };
1209
+ }
1210
+ catch (e) {
1211
+ return { content: [{ type: "text", text: err(e instanceof Error ? e.message : String(e)) }], isError: true };
1212
+ }
1213
+ });
1214
+ server.tool("trade_execute", "Execute a trade. IMPORTANT: Always call trade_preview first and get explicit user confirmation before calling this tool. Supports market and limit orders.", {
1215
+ exchange: z.string().describe("Exchange: pacifica, hyperliquid, or lighter"),
1216
+ symbol: z.string().describe("Trading symbol (e.g., BTC, ETH, SOL)"),
1217
+ side: z.enum(["buy", "sell"]).describe("Order side"),
1218
+ size: z.string().describe("Order size (base currency units)"),
1219
+ orderType: z.enum(["market", "limit"]).default("market").describe("Order type"),
1220
+ price: z.string().optional().describe("Limit price (required for limit orders)"),
1221
+ reduceOnly: z.boolean().default(false).describe("Reduce-only order (close position only)"),
1222
+ }, async ({ exchange, symbol, side, size, orderType, price, reduceOnly }) => {
1223
+ try {
1224
+ const adapter = await getOrCreateAdapter(exchange);
1225
+ let result;
1226
+ if (orderType === "limit") {
1227
+ if (!price)
1228
+ throw new Error("Price is required for limit orders");
1229
+ result = await adapter.limitOrder(symbol, side, price, size, { reduceOnly });
1230
+ }
1231
+ else {
1232
+ result = await adapter.marketOrder(symbol, side, size);
1233
+ }
1234
+ // Fetch updated position
1235
+ const positions = await adapter.getPositions();
1236
+ const pos = positions.find(p => p.symbol.toUpperCase().includes(symbol.toUpperCase()));
1237
+ return {
1238
+ content: [{
1239
+ type: "text",
1240
+ text: ok({
1241
+ executed: true,
1242
+ order: { exchange, symbol, side, size, orderType, price },
1243
+ result,
1244
+ currentPosition: pos ?? null,
1245
+ }, { exchange, type: "execution" }),
1246
+ }],
1247
+ };
1248
+ }
1249
+ catch (e) {
1250
+ return { content: [{ type: "text", text: err(e instanceof Error ? e.message : String(e)) }], isError: true };
1251
+ }
1252
+ });
1253
+ server.tool("trade_close", "Close an existing position. IMPORTANT: Call trade_preview first with the opposite side to show impact, then get user confirmation.", {
1254
+ exchange: z.string().describe("Exchange: pacifica, hyperliquid, or lighter"),
1255
+ symbol: z.string().describe("Symbol to close (e.g., BTC, ETH)"),
1256
+ }, async ({ exchange, symbol }) => {
1257
+ try {
1258
+ const adapter = await getOrCreateAdapter(exchange);
1259
+ const positions = await adapter.getPositions();
1260
+ const pos = positions.find(p => p.symbol.toUpperCase().includes(symbol.toUpperCase()));
1261
+ if (!pos || Number(pos.size) === 0) {
1262
+ return { content: [{ type: "text", text: err(`No open position for ${symbol} on ${exchange}`) }], isError: true };
1263
+ }
1264
+ const closeSide = pos.side === "long" ? "sell" : "buy";
1265
+ const result = await adapter.marketOrder(symbol, closeSide, pos.size);
1266
+ return {
1267
+ content: [{
1268
+ type: "text",
1269
+ text: ok({
1270
+ closed: true,
1271
+ symbol,
1272
+ exchange,
1273
+ closedPosition: { side: pos.side, size: pos.size, entryPrice: pos.entryPrice, unrealizedPnl: pos.unrealizedPnl },
1274
+ result,
1275
+ }, { exchange, type: "close" }),
1276
+ }],
1277
+ };
1278
+ }
1279
+ catch (e) {
1280
+ return { content: [{ type: "text", text: err(e instanceof Error ? e.message : String(e)) }], isError: true };
1281
+ }
1282
+ });
1283
+ // ============================================================
1284
+ // Extended analytics tools
1285
+ // ============================================================
1286
+ server.tool("get_funding_analysis", "Analyze funding income across exchanges — cumulative totals, annualized rates, and per-arb-position breakdown. Great for tracking funding arb profitability.", {
1287
+ period: z.string().default("30d").describe("Time period: 7d, 30d, 90d, or all"),
1288
+ exchange: z.string().optional().describe("Filter by exchange"),
1289
+ symbol: z.string().optional().describe("Filter by symbol"),
1290
+ }, async ({ period, exchange: exFilter, symbol: symFilter }) => {
1291
+ try {
1292
+ const exchanges = exFilter ? [exFilter] : ["pacifica", "hyperliquid", "lighter"];
1293
+ const periodMatch = period.match(/^(\d+)(d|w|m)$/);
1294
+ const sinceMs = periodMatch
1295
+ ? Date.now() - parseInt(periodMatch[1]) * ({ d: 86400000, w: 604800000, m: 2592000000 }[periodMatch[2]] ?? 86400000)
1296
+ : 0;
1297
+ const allFunding = [];
1298
+ const posNotionals = new Map();
1299
+ await Promise.allSettled(exchanges.map(async (ex) => {
1300
+ try {
1301
+ const adapter = await getOrCreateAdapter(ex);
1302
+ const [payments, positions] = await Promise.all([
1303
+ adapter.getFundingPayments(200),
1304
+ adapter.getPositions(),
1305
+ ]);
1306
+ for (const p of payments) {
1307
+ if (sinceMs && p.time < sinceMs)
1308
+ continue;
1309
+ if (symFilter && !p.symbol.toUpperCase().includes(symFilter.toUpperCase()))
1310
+ continue;
1311
+ allFunding.push({ exchange: ex, symbol: p.symbol, payment: Number(p.payment), time: p.time });
1312
+ }
1313
+ for (const p of positions) {
1314
+ if (Number(p.size) > 0)
1315
+ posNotionals.set(`${ex}:${p.symbol}`, Math.abs(Number(p.size) * Number(p.markPrice)));
1316
+ }
1317
+ }
1318
+ catch { /* skip */ }
1319
+ }));
1320
+ allFunding.sort((a, b) => a.time - b.time);
1321
+ // Aggregate by exchange×symbol
1322
+ const byExSym = new Map();
1323
+ let totalFunding = 0;
1324
+ for (const f of allFunding) {
1325
+ totalFunding += f.payment;
1326
+ const key = `${f.exchange}:${f.symbol}`;
1327
+ const e = byExSym.get(key) ?? { exchange: f.exchange, symbol: f.symbol, total: 0, count: 0 };
1328
+ e.total += f.payment;
1329
+ e.count++;
1330
+ byExSym.set(key, e);
1331
+ }
1332
+ const periodDays = sinceMs ? (Date.now() - sinceMs) / 86400000
1333
+ : allFunding.length > 1 ? (allFunding[allFunding.length - 1].time - allFunding[0].time) / 86400000 : 1;
1334
+ // Match to arb positions
1335
+ const arbState = loadArbState();
1336
+ const arbPositions = (arbState?.positions ?? []).map(pos => {
1337
+ const lf = allFunding.filter(f => f.symbol === pos.symbol && f.exchange === pos.longExchange).reduce((s, f) => s + f.payment, 0);
1338
+ const sf = allFunding.filter(f => f.symbol === pos.symbol && f.exchange === pos.shortExchange).reduce((s, f) => s + f.payment, 0);
1339
+ return { symbol: pos.symbol, mode: pos.mode ?? "perp-perp", longExchange: pos.longExchange, shortExchange: pos.shortExchange, netFunding: lf + sf, daysHeld: Math.max(1, Math.round((Date.now() - new Date(pos.entryTime).getTime()) / 86400000)) };
1340
+ }).filter(a => a.netFunding !== 0);
1341
+ const data = {
1342
+ period, periodDays: Math.round(periodDays), totalFunding, totalPayments: allFunding.length,
1343
+ byExchangeSymbol: [...byExSym.values()].sort((a, b) => b.total - a.total).map(e => ({
1344
+ ...e, annualizedRate: (() => {
1345
+ const n = posNotionals.get(`${e.exchange}:${e.symbol}`);
1346
+ return n && n > 0 && periodDays > 0 ? (e.total / Math.max(1, periodDays)) * 365 / n * 100 : null;
1347
+ })(),
1348
+ })),
1349
+ arbPositions,
1350
+ };
1351
+ return { content: [{ type: "text", text: ok(data) }] };
1352
+ }
1353
+ catch (e) {
1354
+ return { content: [{ type: "text", text: err(e instanceof Error ? e.message : String(e)) }], isError: true };
1355
+ }
1356
+ });
1357
+ server.tool("get_pnl_analysis", "P&L analysis combining trading fees and funding income — shows net profit/loss by exchange with daily breakdown", {
1358
+ period: z.string().default("30d").describe("Time period: 7d, 30d, 90d, or all"),
1359
+ exchange: z.string().optional().describe("Filter by exchange"),
1360
+ }, async ({ period, exchange: exFilter }) => {
1361
+ try {
1362
+ const exchanges = exFilter ? [exFilter] : ["pacifica", "hyperliquid", "lighter"];
1363
+ const periodMatch = period.match(/^(\d+)(d|w|m)$/);
1364
+ const sinceMs = periodMatch
1365
+ ? Date.now() - parseInt(periodMatch[1]) * ({ d: 86400000, w: 604800000, m: 2592000000 }[periodMatch[2]] ?? 86400000)
1366
+ : 0;
1367
+ const byExchange = new Map();
1368
+ const dailyMap = new Map();
1369
+ await Promise.allSettled(exchanges.map(async (ex) => {
1370
+ try {
1371
+ const adapter = await getOrCreateAdapter(ex);
1372
+ const [trades, funding] = await Promise.all([adapter.getTradeHistory(200), adapter.getFundingPayments(200)]);
1373
+ const exData = byExchange.get(ex) ?? { trades: 0, volume: 0, fees: 0, funding: 0 };
1374
+ for (const t of trades) {
1375
+ if (sinceMs && t.time < sinceMs)
1376
+ continue;
1377
+ const notional = Number(t.price) * Number(t.size);
1378
+ exData.trades++;
1379
+ exData.volume += notional;
1380
+ exData.fees += Number(t.fee);
1381
+ const day = new Date(t.time).toISOString().slice(0, 10);
1382
+ const d = dailyMap.get(day) ?? { fees: 0, funding: 0 };
1383
+ d.fees += Number(t.fee);
1384
+ dailyMap.set(day, d);
1385
+ }
1386
+ for (const f of funding) {
1387
+ if (sinceMs && f.time < sinceMs)
1388
+ continue;
1389
+ exData.funding += Number(f.payment);
1390
+ const day = new Date(f.time).toISOString().slice(0, 10);
1391
+ const d = dailyMap.get(day) ?? { fees: 0, funding: 0 };
1392
+ d.funding += Number(f.payment);
1393
+ dailyMap.set(day, d);
1394
+ }
1395
+ byExchange.set(ex, exData);
1396
+ }
1397
+ catch { /* skip */ }
1398
+ }));
1399
+ let totalFees = 0, totalFunding = 0, totalVolume = 0, totalTrades = 0;
1400
+ for (const d of byExchange.values()) {
1401
+ totalFees += d.fees;
1402
+ totalFunding += d.funding;
1403
+ totalVolume += d.volume;
1404
+ totalTrades += d.trades;
1405
+ }
1406
+ const data = {
1407
+ period, totalTrades, totalVolume, totalFees, totalFunding, netPnl: totalFunding - totalFees,
1408
+ byExchange: Object.fromEntries([...byExchange.entries()].map(([ex, d]) => [ex, { ...d, netPnl: d.funding - d.fees }])),
1409
+ daily: [...dailyMap.entries()].sort().map(([date, d]) => ({ date, fees: d.fees, funding: d.funding, net: d.funding - d.fees })),
1410
+ };
1411
+ return { content: [{ type: "text", text: ok(data) }] };
1412
+ }
1413
+ catch (e) {
1414
+ return { content: [{ type: "text", text: err(e instanceof Error ? e.message : String(e)) }], isError: true };
1415
+ }
1416
+ });
1417
+ server.tool("get_arb_compare", "Compare active arbitrage positions side by side — ROI, funding income, price PnL, and annualized returns for each position", {
1418
+ period: z.string().default("all").describe("Funding lookup period: 7d, 30d, 90d, or all"),
1419
+ }, async ({ period }) => {
1420
+ try {
1421
+ const arbState = loadArbState();
1422
+ const positions = arbState?.positions ?? [];
1423
+ if (positions.length === 0) {
1424
+ return { content: [{ type: "text", text: ok({ positions: [], message: "No active arb positions" }) }] };
1425
+ }
1426
+ const periodMatch = period === "all" ? null : period.match(/^(\d+)(d|w|m)$/);
1427
+ const sinceMs = periodMatch
1428
+ ? Date.now() - parseInt(periodMatch[1]) * ({ d: 86400000, w: 604800000, m: 2592000000 }[periodMatch[2]] ?? 86400000)
1429
+ : 0;
1430
+ const exchangeSet = new Set();
1431
+ for (const pos of positions) {
1432
+ exchangeSet.add(pos.longExchange);
1433
+ exchangeSet.add(pos.shortExchange);
1434
+ }
1435
+ const fundingByExSym = new Map();
1436
+ const currentPrices = new Map();
1437
+ await Promise.allSettled([...exchangeSet].map(async (ex) => {
1438
+ try {
1439
+ const adapter = await getOrCreateAdapter(ex);
1440
+ const [funding, livePos] = await Promise.all([adapter.getFundingPayments(200), adapter.getPositions()]);
1441
+ for (const f of funding) {
1442
+ if (sinceMs && f.time < sinceMs)
1443
+ continue;
1444
+ const key = `${ex}:${f.symbol}`;
1445
+ fundingByExSym.set(key, (fundingByExSym.get(key) ?? 0) + Number(f.payment));
1446
+ }
1447
+ for (const p of livePos)
1448
+ currentPrices.set(`${ex}:${p.symbol}`, Number(p.markPrice));
1449
+ }
1450
+ catch { /* skip */ }
1451
+ }));
1452
+ const rows = positions.map(pos => {
1453
+ const daysHeld = Math.max(1, Math.round((Date.now() - new Date(pos.entryTime).getTime()) / 86400000));
1454
+ const funding = (fundingByExSym.get(`${pos.longExchange}:${pos.symbol}`) ?? 0) + (fundingByExSym.get(`${pos.shortExchange}:${pos.symbol}`) ?? 0);
1455
+ const longPrice = currentPrices.get(`${pos.longExchange}:${pos.symbol}`) ?? pos.entryLongPrice;
1456
+ const shortPrice = currentPrices.get(`${pos.shortExchange}:${pos.symbol}`) ?? pos.entryShortPrice;
1457
+ const pricePnl = (longPrice - pos.entryLongPrice) * pos.longSize + (pos.entryShortPrice - shortPrice) * pos.shortSize;
1458
+ const totalPnl = pricePnl + funding;
1459
+ const notional = pos.entryLongPrice * pos.longSize + pos.entryShortPrice * pos.shortSize;
1460
+ const roi = notional > 0 ? (totalPnl / notional) * 100 : 0;
1461
+ const fundingRoi = notional > 0 ? (funding / notional) * 100 : 0;
1462
+ return { symbol: pos.symbol, mode: pos.mode ?? "perp-perp", longExchange: pos.longExchange, shortExchange: pos.shortExchange, daysHeld, funding, pricePnl, totalPnl, roi, annualizedRoi: daysHeld > 0 ? (fundingRoi / daysHeld) * 365 : 0 };
1463
+ }).sort((a, b) => b.totalPnl - a.totalPnl);
1464
+ const totals = { funding: rows.reduce((s, r) => s + r.funding, 0), pricePnl: rows.reduce((s, r) => s + r.pricePnl, 0), totalPnl: rows.reduce((s, r) => s + r.totalPnl, 0) };
1465
+ return { content: [{ type: "text", text: ok({ period, positionCount: rows.length, positions: rows, totals }) }] };
1466
+ }
1467
+ catch (e) {
1468
+ return { content: [{ type: "text", text: err(e instanceof Error ? e.message : String(e)) }], isError: true };
1469
+ }
1470
+ });
1471
+ // ============================================================
1472
+ // Additional Resources
1473
+ // ============================================================
1474
+ server.resource("market_prices", "market://prices", { mimeType: "application/json", description: "Live cross-exchange prices for all perpetual futures markets" }, async () => {
1475
+ const prices = {};
1476
+ await Promise.allSettled(["pacifica", "hyperliquid", "lighter"].map(async (ex) => {
1477
+ try {
1478
+ const adapter = await getOrCreateAdapter(ex);
1479
+ const markets = await adapter.getMarkets();
1480
+ for (const m of markets) {
1481
+ if (!prices[m.symbol])
1482
+ prices[m.symbol] = {};
1483
+ prices[m.symbol][ex] = m.markPrice;
1484
+ }
1485
+ }
1486
+ catch { /* skip */ }
1487
+ }));
1488
+ return { contents: [{ uri: "market://prices", text: JSON.stringify({ ok: true, data: prices, meta: { timestamp: new Date().toISOString() } }, null, 2), mimeType: "application/json" }] };
1489
+ });
1490
+ server.resource("funding_rates", "market://funding-rates", { mimeType: "application/json", description: "Current funding rates across all exchanges with spread analysis" }, async () => {
1491
+ try {
1492
+ const rates = await fetchAllFundingRates();
1493
+ return { contents: [{ uri: "market://funding-rates", text: JSON.stringify({ ok: true, data: rates, meta: { timestamp: new Date().toISOString() } }, null, 2), mimeType: "application/json" }] };
1494
+ }
1495
+ catch (e) {
1496
+ return { contents: [{ uri: "market://funding-rates", text: JSON.stringify({ ok: false, error: String(e) }), mimeType: "application/json" }] };
1497
+ }
1498
+ });
1499
+ // ============================================================
1500
+ // Prompts
1501
+ // ============================================================
1502
+ server.prompt("trading-guide", "Step-by-step guide for trading with perp-cli via this MCP server. Covers market data lookup, trade preview, execution, and position management.", {}, () => ({
1503
+ messages: [{
1504
+ role: "user",
1505
+ content: {
1506
+ type: "text",
1507
+ text: `You are a helpful trading assistant with access to perp-cli MCP tools. Follow this workflow:
1508
+
1509
+ ## Trading Workflow
1510
+
1511
+ 1. **Check Market Data First**
1512
+ - Use \`get_prices\` to compare prices across exchanges
1513
+ - Use \`get_orderbook\` to check liquidity and spread
1514
+ - Use \`get_funding_rates\` to check funding rate direction
1515
+
1516
+ 2. **Check Account State**
1517
+ - Use \`get_balance\` to verify available margin
1518
+ - Use \`get_positions\` to see existing positions
1519
+ - Use \`portfolio\` for a full cross-exchange overview
1520
+
1521
+ 3. **Preview Before Executing**
1522
+ - ALWAYS use \`trade_preview\` before any trade
1523
+ - Show the user: estimated price, fees, margin impact
1524
+ - Wait for explicit user confirmation
1525
+
1526
+ 4. **Execute with Confirmation**
1527
+ - Only call \`trade_execute\` after user says "yes" or "confirm"
1528
+ - Never execute trades without showing the preview first
1529
+
1530
+ 5. **Monitor After Execution**
1531
+ - Use \`get_positions\` to verify the trade went through
1532
+ - Use \`get_pnl_analysis\` to track performance over time
1533
+
1534
+ ## Safety Rules
1535
+ - Never execute trades without user confirmation
1536
+ - Always show the preview (estimated cost, risk) before executing
1537
+ - If the user asks to "buy" or "sell", preview first, then ask for confirmation
1538
+ - Warn about high-leverage trades or large position sizes
1539
+
1540
+ ## Supported Exchanges
1541
+ - **pacifica** (Solana) — SOL-based perpetual futures
1542
+ - **hyperliquid** (HyperEVM) — Low-fee perpetuals with HIP-3 DEXes
1543
+ - **lighter** (Ethereum) — Ethereum-based perpetual futures`,
1544
+ },
1545
+ }],
1546
+ }));
1547
+ server.prompt("arb-strategy", "Funding rate arbitrage strategy guide. How to find, execute, and manage cross-exchange arbitrage positions using perp-cli.", {}, () => ({
1548
+ messages: [{
1549
+ role: "user",
1550
+ content: {
1551
+ type: "text",
1552
+ text: `You are a funding rate arbitrage specialist with access to perp-cli MCP tools. Help users find and manage arb opportunities.
1553
+
1554
+ ## Funding Rate Arbitrage
1555
+
1556
+ Funding arb exploits rate differences between exchanges: go LONG on the exchange paying you funding, SHORT on the one charging you.
1557
+
1558
+ ### Step 1: Scan for Opportunities
1559
+ Use \`arb_scan\` to find symbols with large funding rate spreads:
1560
+ - Look for annualized spreads > 10% (good), > 20% (excellent)
1561
+ - Check that the spread has been persistent (not just a spike)
1562
+
1563
+ ### Step 2: Validate with Market Data
1564
+ - Use \`get_funding_rates\` to see current rates
1565
+ - Use \`get_orderbook\` on both exchanges to check liquidity
1566
+ - Use \`get_prices\` to compare mark prices (large deviation = risk)
1567
+
1568
+ ### Step 3: Execute the Arb
1569
+ For each leg:
1570
+ 1. \`trade_preview\` the LONG leg on the exchange with positive funding
1571
+ 2. \`trade_preview\` the SHORT leg on the exchange with negative funding
1572
+ 3. Show both previews to user, get confirmation
1573
+ 4. Execute both legs
1574
+
1575
+ ### Step 4: Monitor Performance
1576
+ - Use \`get_arb_compare\` to track all arb positions side by side
1577
+ - Use \`get_funding_analysis\` to see cumulative funding income
1578
+ - Use \`get_pnl_analysis\` to see net P&L (funding income - trading fees)
1579
+
1580
+ ### Step 5: Close When Spread Compresses
1581
+ When the funding spread narrows below 2-3%, consider closing:
1582
+ - Use \`trade_close\` on both legs
1583
+ - Check final P&L with \`get_pnl_analysis\`
1584
+
1585
+ ### Risk Factors
1586
+ - **Basis risk**: Prices may diverge between exchanges
1587
+ - **Liquidation risk**: Each leg can be liquidated independently
1588
+ - **Funding reversal**: Rates can flip direction
1589
+ - **Bridge costs**: Moving funds between exchanges has costs`,
1590
+ },
1591
+ }],
1592
+ }));
1150
1593
  // ── Start server ──
1151
1594
  async function main() {
1152
1595
  const transport = new StdioServerTransport();
@@ -2,6 +2,30 @@ export interface ExchangeFees {
2
2
  taker: number;
3
3
  maker: number;
4
4
  }
5
+ export interface AlertRule {
6
+ id: string;
7
+ symbol: string;
8
+ exchange: string;
9
+ threshold: number;
10
+ direction: "above" | "below";
11
+ enabled: boolean;
12
+ }
13
+ export interface AlertSettings {
14
+ discord?: {
15
+ botToken: string;
16
+ userId: string;
17
+ channelId?: string;
18
+ };
19
+ telegram?: {
20
+ botToken: string;
21
+ chatId: string;
22
+ };
23
+ rules: AlertRule[];
24
+ /** Check interval in seconds (default: 300 = 5 min) */
25
+ intervalSec: number;
26
+ /** Cooldown per symbol in minutes after alert fires (default: 60) */
27
+ cooldownMin: number;
28
+ }
5
29
  export interface Settings {
6
30
  /** Default exchange when -e flag is omitted */
7
31
  defaultExchange: string;
@@ -20,6 +44,8 @@ export interface Settings {
20
44
  };
21
45
  /** Per-exchange fee tiers (fetched from exchange APIs) */
22
46
  fees: Record<string, ExchangeFees>;
47
+ /** Alert configuration */
48
+ alerts: AlertSettings;
23
49
  }
24
50
  export declare function loadSettings(): Settings;
25
51
  export declare function saveSettings(settings: Settings): void;
package/dist/settings.js CHANGED
@@ -19,10 +19,15 @@ const DEFAULTS = {
19
19
  pacifica: { taker: 0.00035, maker: 0.0001 },
20
20
  lighter: { taker: 0, maker: 0 },
21
21
  },
22
+ alerts: {
23
+ rules: [],
24
+ intervalSec: 300,
25
+ cooldownMin: 60,
26
+ },
22
27
  };
23
28
  export function loadSettings() {
24
29
  if (!existsSync(SETTINGS_FILE))
25
- return { ...DEFAULTS, referralCodes: { ...DEFAULTS.referralCodes }, fees: { ...DEFAULTS.fees } };
30
+ return { ...DEFAULTS, referralCodes: { ...DEFAULTS.referralCodes }, fees: { ...DEFAULTS.fees }, alerts: { ...DEFAULTS.alerts } };
26
31
  try {
27
32
  const stored = JSON.parse(readFileSync(SETTINGS_FILE, "utf-8"));
28
33
  // Merge stored fees with defaults (so new exchanges get defaults)
@@ -47,11 +52,18 @@ export function loadSettings() {
47
52
  hyperliquid: stored.referralApplied?.hyperliquid ?? DEFAULTS.referralApplied.hyperliquid,
48
53
  lighter: stored.referralApplied?.lighter ?? DEFAULTS.referralApplied.lighter,
49
54
  },
55
+ alerts: {
56
+ discord: stored.alerts?.discord ?? DEFAULTS.alerts.discord,
57
+ telegram: stored.alerts?.telegram ?? DEFAULTS.alerts.telegram,
58
+ rules: Array.isArray(stored.alerts?.rules) ? stored.alerts.rules : DEFAULTS.alerts.rules,
59
+ intervalSec: stored.alerts?.intervalSec ?? DEFAULTS.alerts.intervalSec,
60
+ cooldownMin: stored.alerts?.cooldownMin ?? DEFAULTS.alerts.cooldownMin,
61
+ },
50
62
  fees,
51
63
  };
52
64
  }
53
65
  catch {
54
- return { ...DEFAULTS, referralCodes: { ...DEFAULTS.referralCodes }, fees: { ...DEFAULTS.fees } };
66
+ return { ...DEFAULTS, referralCodes: { ...DEFAULTS.referralCodes }, fees: { ...DEFAULTS.fees }, alerts: { ...DEFAULTS.alerts } };
55
67
  }
56
68
  }
57
69
  export function saveSettings(settings) {
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Telegram Bot API client.
3
+ *
4
+ * Sends messages to a user via Telegram Bot API (REST only).
5
+ * Requires: bot token (from @BotFather) + chat ID.
6
+ */
7
+ export interface TelegramConfig {
8
+ botToken: string;
9
+ chatId: string;
10
+ }
11
+ /**
12
+ * Send a message to the configured chat.
13
+ */
14
+ export declare function sendTelegramMessage(config: TelegramConfig, message: string, opts?: {
15
+ parseMode?: "Markdown" | "HTML";
16
+ }): Promise<void>;
17
+ /**
18
+ * Validate bot token by calling getMe.
19
+ * Returns the bot's username if valid.
20
+ */
21
+ export declare function validateTelegramBot(token: string): Promise<{
22
+ valid: boolean;
23
+ username: string;
24
+ }>;
25
+ /**
26
+ * Get recent updates to find the chat ID from a user who messaged the bot.
27
+ * User must send /start to the bot first.
28
+ */
29
+ export declare function getRecentChatId(token: string): Promise<{
30
+ chatId: string;
31
+ firstName: string;
32
+ } | null>;
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Telegram Bot API client.
3
+ *
4
+ * Sends messages to a user via Telegram Bot API (REST only).
5
+ * Requires: bot token (from @BotFather) + chat ID.
6
+ */
7
+ const TG_API = "https://api.telegram.org";
8
+ /**
9
+ * Send a message to the configured chat.
10
+ */
11
+ export async function sendTelegramMessage(config, message, opts) {
12
+ const res = await fetch(`${TG_API}/bot${config.botToken}/sendMessage`, {
13
+ method: "POST",
14
+ headers: { "Content-Type": "application/json" },
15
+ body: JSON.stringify({
16
+ chat_id: config.chatId,
17
+ text: message,
18
+ parse_mode: opts?.parseMode,
19
+ }),
20
+ });
21
+ if (!res.ok) {
22
+ const body = await res.text();
23
+ throw new Error(`Telegram send failed (${res.status}): ${body}`);
24
+ }
25
+ const data = (await res.json());
26
+ if (!data.ok) {
27
+ throw new Error(`Telegram API error: ${data.description ?? "unknown"}`);
28
+ }
29
+ }
30
+ /**
31
+ * Validate bot token by calling getMe.
32
+ * Returns the bot's username if valid.
33
+ */
34
+ export async function validateTelegramBot(token) {
35
+ try {
36
+ const res = await fetch(`${TG_API}/bot${token}/getMe`);
37
+ if (!res.ok)
38
+ return { valid: false, username: "" };
39
+ const data = (await res.json());
40
+ if (!data.ok || !data.result)
41
+ return { valid: false, username: "" };
42
+ return { valid: true, username: `@${data.result.username}` };
43
+ }
44
+ catch {
45
+ return { valid: false, username: "" };
46
+ }
47
+ }
48
+ /**
49
+ * Get recent updates to find the chat ID from a user who messaged the bot.
50
+ * User must send /start to the bot first.
51
+ */
52
+ export async function getRecentChatId(token) {
53
+ try {
54
+ const res = await fetch(`${TG_API}/bot${token}/getUpdates?limit=10&offset=-10`);
55
+ if (!res.ok)
56
+ return null;
57
+ const data = (await res.json());
58
+ if (!data.ok || !data.result?.length)
59
+ return null;
60
+ // Find the most recent /start message or any message
61
+ for (const update of data.result.reverse()) {
62
+ if (update.message?.chat?.id) {
63
+ return {
64
+ chatId: String(update.message.chat.id),
65
+ firstName: update.message.chat.first_name ?? "",
66
+ };
67
+ }
68
+ }
69
+ return null;
70
+ }
71
+ catch {
72
+ return null;
73
+ }
74
+ }
package/dist/utils.d.ts CHANGED
@@ -3,6 +3,8 @@ export declare function formatUsd(value: string | number): string;
3
3
  export declare function formatPnl(value: string | number): string;
4
4
  export declare function formatPercent(value: string | number): string;
5
5
  export declare function makeTable(head: string[], rows: string[][]): string;
6
+ /** Render a sparkline from numeric values. Returns unicode bar chart string. */
7
+ export declare function sparkline(values: number[], width?: number): string;
6
8
  export declare function printJson(data: unknown): void;
7
9
  export declare function errorAndExit(msg: string): never;
8
10
  /** Standard JSON response envelope */