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 +29 -0
- package/dist/commands/account.js +60 -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/arb/index.js +12 -4
- package/dist/commands/arb-auto.js +50 -19
- package/dist/commands/arb.js +43 -4
- package/dist/commands/history.js +329 -70
- package/dist/discord.d.ts +29 -0
- package/dist/discord.js +66 -0
- package/dist/exchanges/hyperliquid-spot.d.ts +1 -0
- package/dist/exchanges/hyperliquid-spot.js +25 -10
- package/dist/exchanges/hyperliquid.d.ts +2 -0
- package/dist/exchanges/hyperliquid.js +10 -2
- package/dist/exchanges/lighter-spot.d.ts +6 -0
- package/dist/exchanges/lighter-spot.js +80 -31
- package/dist/exchanges/lighter.d.ts +3 -0
- package/dist/exchanges/lighter.js +46 -25
- package/dist/exchanges/pacifica.js +26 -4
- package/dist/index.js +325 -43
- 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/dist/utils.d.ts +2 -0
- package/dist/utils.js +32 -0
- package/package.json +1 -1
- package/skills/perp-cli/package.json +35 -0
package/dist/mcp-server.js
CHANGED
|
@@ -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();
|
package/dist/settings.d.ts
CHANGED
|
@@ -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>;
|
package/dist/telegram.js
ADDED
|
@@ -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 */
|