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/dist/index.js
CHANGED
|
@@ -35,7 +35,8 @@ import { registerPlanCommands } from "./commands/plan.js";
|
|
|
35
35
|
// funding merged into arb.ts
|
|
36
36
|
import { registerBacktestCommands } from "./commands/backtest.js";
|
|
37
37
|
import { registerDashboardCommands } from "./commands/dashboard.js";
|
|
38
|
-
import { registerInitCommand
|
|
38
|
+
import { registerInitCommand } from "./commands/init.js";
|
|
39
|
+
import { registerAlertCommands } from "./commands/alerts.js";
|
|
39
40
|
import { loadSettings, saveSettings } from "./settings.js";
|
|
40
41
|
import { setSharedApiNetwork } from "./shared-api.js";
|
|
41
42
|
const _require = createRequire(import.meta.url);
|
|
@@ -312,6 +313,7 @@ registerPlanCommands(program, getAdapter, isJson);
|
|
|
312
313
|
registerBacktestCommands(program, isJson);
|
|
313
314
|
registerDashboardCommands(program, getAdapterForExchange, isJson, getHLAdapterForDex);
|
|
314
315
|
registerInitCommand(program);
|
|
316
|
+
registerAlertCommands(program, isJson);
|
|
315
317
|
// Agent discovery: perp api-spec — deprecated, use 'perp agent schema'
|
|
316
318
|
const apiSpecCmd = program
|
|
317
319
|
.command("api-spec")
|
|
@@ -619,62 +621,74 @@ if (rawArgs.length === 0 || (!hasSubcommand && !rawArgs.includes("-h") && !rawAr
|
|
|
619
621
|
const hasEnvKey = !!(process.env.PRIVATE_KEY || process.env.PACIFICA_PRIVATE_KEY ||
|
|
620
622
|
process.env.HL_PRIVATE_KEY || process.env.HYPERLIQUID_PRIVATE_KEY ||
|
|
621
623
|
process.env.LIGHTER_PRIVATE_KEY);
|
|
624
|
+
// ── ASCII banner ──
|
|
625
|
+
const banner = [
|
|
626
|
+
chalk.cyan(" ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓"),
|
|
627
|
+
chalk.cyan(" ┃") + chalk.white.bold(" perp-cli ") + chalk.gray(`v${_pkg.version}`) + " ".repeat(Math.max(0, 32 - _pkg.version.length)) + chalk.cyan("┃"),
|
|
628
|
+
chalk.cyan(" ┃") + chalk.gray(" Multi-DEX Perpetual Futures ") + chalk.cyan("┃"),
|
|
629
|
+
chalk.cyan(" ┃") + chalk.gray(" Pacifica · Hyperliquid · Lighter ") + chalk.cyan("┃"),
|
|
630
|
+
chalk.cyan(" ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛"),
|
|
631
|
+
];
|
|
632
|
+
console.log("\n" + banner.join("\n"));
|
|
622
633
|
if (!status.hasWallets && !hasEnvKey && !settings.defaultExchange) {
|
|
623
634
|
// Fresh install — onboarding
|
|
624
|
-
console.log(chalk.
|
|
625
|
-
console.log("
|
|
626
|
-
console.log(` Get started: ${chalk.cyan("perp wallet set <exchange> <key>")}`);
|
|
635
|
+
console.log(chalk.yellow.bold("\n ⚡ Get started:\n"));
|
|
636
|
+
console.log(` ${chalk.cyan("perp wallet set <exchange> <key>")}`);
|
|
627
637
|
console.log(chalk.gray(`\n Or explore without a wallet:`));
|
|
628
638
|
console.log(` ${chalk.green("perp market list")} available markets`);
|
|
629
|
-
console.log(` ${chalk.green("perp -e
|
|
639
|
+
console.log(` ${chalk.green("perp -e hl market list")} Hyperliquid markets`);
|
|
630
640
|
console.log(` ${chalk.green("perp arb scan")} funding rate arbitrage`);
|
|
631
641
|
console.log(` ${chalk.green("perp --help")} all commands\n`);
|
|
632
642
|
}
|
|
633
643
|
else {
|
|
634
|
-
// Configured — show status
|
|
635
|
-
const
|
|
636
|
-
const
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
const
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
? w.address.slice(0, 6) + "..." + w.address.slice(-4)
|
|
646
|
-
: w.address;
|
|
647
|
-
console.log(` ${chalk.cyan(exchange.padEnd(14))} ${chalk.white(walletName)} ${chalk.gray(addr)}`);
|
|
648
|
-
}
|
|
644
|
+
// Configured — show exchange status + balance
|
|
645
|
+
const EX_NAMES = ["pacifica", "hyperliquid", "lighter"];
|
|
646
|
+
const exLabel = (e) => e === "pacifica" ? "Pacifica" : e === "hyperliquid" ? "Hyperliquid" : "Lighter";
|
|
647
|
+
// Ping + balance in parallel (with 5s timeout to keep landing fast)
|
|
648
|
+
const withTimeout = (p, ms) => Promise.race([p, new Promise((_, rej) => setTimeout(() => rej(new Error("timeout")), ms))]);
|
|
649
|
+
const statusResults = await Promise.allSettled(EX_NAMES.map(async (ex) => {
|
|
650
|
+
try {
|
|
651
|
+
const adapter = await withTimeout(getAdapterForExchange(ex), 5000);
|
|
652
|
+
const [balance, positions] = await withTimeout(Promise.all([adapter.getBalance(), adapter.getPositions()]), 5000);
|
|
653
|
+
const posCount = positions.filter(p => Number(p.size) > 0).length;
|
|
654
|
+
return { exchange: ex, ok: true, equity: Number(balance.equity), positions: posCount };
|
|
649
655
|
}
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
656
|
+
catch {
|
|
657
|
+
return { exchange: ex, ok: false, equity: 0, positions: 0 };
|
|
658
|
+
}
|
|
659
|
+
}));
|
|
660
|
+
console.log(chalk.white.bold("\n Exchanges:"));
|
|
661
|
+
let totalEquity = 0;
|
|
662
|
+
let totalPositions = 0;
|
|
663
|
+
for (const r of statusResults) {
|
|
664
|
+
if (r.status !== "fulfilled")
|
|
665
|
+
continue;
|
|
666
|
+
const s = r.value;
|
|
667
|
+
const icon = s.ok ? chalk.green("●") : chalk.red("○");
|
|
668
|
+
const eq = s.ok ? chalk.white(`$${Number(s.equity).toLocaleString("en", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`) : chalk.gray("—");
|
|
669
|
+
const pos = s.ok && s.positions > 0 ? chalk.yellow(` ${s.positions} pos`) : "";
|
|
670
|
+
console.log(` ${icon} ${chalk.cyan(exLabel(s.exchange).padEnd(14))} ${eq}${pos}`);
|
|
671
|
+
if (s.ok) {
|
|
672
|
+
totalEquity += s.equity;
|
|
673
|
+
totalPositions += s.positions;
|
|
665
674
|
}
|
|
666
675
|
}
|
|
667
|
-
|
|
676
|
+
if (totalEquity > 0) {
|
|
677
|
+
console.log(chalk.gray(" ─".repeat(20)));
|
|
678
|
+
console.log(` ${chalk.white.bold("Total".padEnd(16))} ${chalk.white.bold(`$${totalEquity.toLocaleString("en", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`)}${totalPositions > 0 ? chalk.yellow(` ${totalPositions} pos`) : ""}`);
|
|
679
|
+
}
|
|
680
|
+
console.log(chalk.white.bold("\n Commands:"));
|
|
668
681
|
console.log(` ${chalk.green("perp portfolio")} balances + positions + risk`);
|
|
669
|
-
console.log(` ${chalk.green("perp
|
|
682
|
+
console.log(` ${chalk.green("perp status")} full dashboard`);
|
|
670
683
|
console.log(` ${chalk.green("perp arb scan")} funding rate arbitrage`);
|
|
671
|
-
console.log(` ${chalk.green("perp dashboard")} live monitoring`);
|
|
684
|
+
console.log(` ${chalk.green("perp dashboard")} live web monitoring`);
|
|
672
685
|
console.log(` ${chalk.green("perp --help")} all commands\n`);
|
|
673
686
|
}
|
|
674
687
|
}
|
|
675
688
|
catch {
|
|
676
689
|
program.help();
|
|
677
690
|
}
|
|
691
|
+
setTimeout(() => process.exit(0), 500);
|
|
678
692
|
})();
|
|
679
693
|
}
|
|
680
694
|
else {
|
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;
|