perp-cli 0.3.19 → 0.4.2
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 +4 -1
- package/dist/arb/utils.d.ts +2 -0
- package/dist/arb/utils.js +1 -1
- package/dist/commands/agent.js +19 -11
- package/dist/commands/arb/index.js +288 -31
- package/dist/commands/arb-auto.js +242 -63
- package/dist/commands/init.js +1 -1
- package/dist/commands/settings.d.ts +2 -1
- package/dist/commands/settings.js +133 -2
- package/dist/commands/trade.js +89 -35
- package/dist/constants.d.ts +14 -0
- package/dist/constants.js +40 -0
- package/dist/exchanges/hyperliquid.js +13 -22
- package/dist/exchanges/lighter.js +50 -3
- package/dist/exchanges/pacifica.js +1 -1
- package/dist/index.js +7 -7
- package/dist/mcp-server.js +6 -6
- package/dist/settings.d.ts +6 -0
- package/dist/settings.js +18 -2
- package/dist/smart-order.d.ts +44 -0
- package/dist/smart-order.js +134 -0
- package/dist/trade-validator.js +2 -1
- package/package.json +1 -1
- package/skills/perp-cli/SKILL.md +31 -8
package/README.md
CHANGED
|
@@ -3,8 +3,11 @@
|
|
|
3
3
|
Multi-DEX perpetual futures CLI — **Pacifica** (Solana), **Hyperliquid** (HyperEVM), **Lighter** (Ethereum).
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
|
-
npm install -g perp-cli
|
|
6
|
+
npm install -g perp-cli # global install
|
|
7
7
|
perp --json portfolio
|
|
8
|
+
|
|
9
|
+
# Or without global install (restricted environments)
|
|
10
|
+
npx -y perp-cli --json portfolio
|
|
8
11
|
```
|
|
9
12
|
|
|
10
13
|
## Features
|
package/dist/arb/utils.d.ts
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
* Arb utilities: settlement timing strategy, basis risk monitoring, and webhook notifications.
|
|
3
3
|
*/
|
|
4
4
|
import type { ExchangeAdapter } from "../exchanges/interface.js";
|
|
5
|
+
/** Settlement schedules per exchange (UTC hours when settlement occurs) */
|
|
6
|
+
export declare const SETTLEMENT_SCHEDULES: Record<string, number[]>;
|
|
5
7
|
export type SettleStrategy = "block" | "aggressive" | "off";
|
|
6
8
|
/**
|
|
7
9
|
* Get the most recent settlement time for an exchange.
|
package/dist/arb/utils.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
// ── Settlement Timing Strategy ──
|
|
5
5
|
/** Settlement schedules per exchange (UTC hours when settlement occurs) */
|
|
6
|
-
const SETTLEMENT_SCHEDULES = {
|
|
6
|
+
export const SETTLEMENT_SCHEDULES = {
|
|
7
7
|
hyperliquid: Array.from({ length: 24 }, (_, i) => i), // every hour
|
|
8
8
|
pacifica: Array.from({ length: 24 }, (_, i) => i), // every hour
|
|
9
9
|
lighter: Array.from({ length: 24 }, (_, i) => i), // every hour
|
package/dist/commands/agent.js
CHANGED
|
@@ -134,7 +134,7 @@ export function registerAgentCommands(program, getAdapter, isJson) {
|
|
|
134
134
|
"perp account orders --json",
|
|
135
135
|
"perp account history --json",
|
|
136
136
|
"perp account trades --json",
|
|
137
|
-
"perp
|
|
137
|
+
"perp portfolio --json",
|
|
138
138
|
],
|
|
139
139
|
description: "Read account state: balances, positions, open orders, trade history",
|
|
140
140
|
},
|
|
@@ -142,14 +142,14 @@ export function registerAgentCommands(program, getAdapter, isJson) {
|
|
|
142
142
|
category: "trading",
|
|
143
143
|
commands: [
|
|
144
144
|
"perp trade check <symbol> <side> <size> --json",
|
|
145
|
-
"perp trade market <symbol> <buy|sell> <size> --json --client-id <id>",
|
|
145
|
+
"perp trade market <symbol> <buy|sell> <size> --json [--smart] --client-id <id>",
|
|
146
146
|
"perp trade limit <symbol> <buy|sell> <price> <size> --json --client-id <id>",
|
|
147
147
|
"perp trade cancel <symbol> <orderId> --json",
|
|
148
148
|
"perp trade cancel-all --json",
|
|
149
|
-
"perp trade close <symbol> --json",
|
|
150
|
-
"perp trade close-all --json",
|
|
151
|
-
"perp trade flatten --json",
|
|
152
|
-
"perp trade reduce <symbol> <percent> --json",
|
|
149
|
+
"perp trade close <symbol> --json [--smart]",
|
|
150
|
+
"perp trade close-all --json [--smart]",
|
|
151
|
+
"perp trade flatten --json [--smart]",
|
|
152
|
+
"perp trade reduce <symbol> <percent> --json [--smart]",
|
|
153
153
|
"perp trade stop <symbol> <side> <stopPrice> <size> --json",
|
|
154
154
|
"perp trade tpsl <symbol> <side> --tp <price> --sl <price> --json",
|
|
155
155
|
"perp trade twap <symbol> <side> <size> <duration> --json",
|
|
@@ -170,13 +170,21 @@ export function registerAgentCommands(program, getAdapter, isJson) {
|
|
|
170
170
|
category: "arbitrage",
|
|
171
171
|
commands: [
|
|
172
172
|
"perp arb funding --json",
|
|
173
|
-
"perp arb
|
|
173
|
+
"perp arb scan --min 5 --json",
|
|
174
174
|
"perp arb scan --json",
|
|
175
|
+
"perp arb exec <SYM> <longEx> <shortEx> <$> --leverage 2 --isolated --json",
|
|
176
|
+
"perp arb status --json",
|
|
177
|
+
"perp arb close <SYM> --json",
|
|
178
|
+
"perp arb close <SYM> --dry-run --json",
|
|
179
|
+
"perp arb close <SYM> --pair <longEx>:<shortEx> --json",
|
|
180
|
+
"perp arb funding-earned --json",
|
|
181
|
+
"perp arb funding-earned --period 30 --json",
|
|
182
|
+
"perp arb history --json",
|
|
175
183
|
"perp arb dex --json",
|
|
176
184
|
"perp arb dex-monitor --min 10",
|
|
177
185
|
"perp gap show --json",
|
|
178
186
|
],
|
|
179
|
-
description: "Cross-exchange
|
|
187
|
+
description: "Cross-exchange funding arb: scan → exec → status → funding-earned → close. Plus cross-dex arb and price gaps.",
|
|
180
188
|
},
|
|
181
189
|
{
|
|
182
190
|
category: "risk_management",
|
|
@@ -338,10 +346,10 @@ export function registerAgentCommands(program, getAdapter, isJson) {
|
|
|
338
346
|
}
|
|
339
347
|
}
|
|
340
348
|
else if (g.includes("arb") || g.includes("arbitrage") || g.includes("funding")) {
|
|
341
|
-
steps.push({ step: 1, command: "perp arb
|
|
349
|
+
steps.push({ step: 1, command: "perp arb scan --min 5 --json", description: "Scan funding rate arbitrage opportunities" }, { step: 2, command: "perp arb exec <SYM> <longEx> <shortEx> <$> --leverage 2 --isolated --dry-run --json", description: "Dry-run arb execution" });
|
|
342
350
|
}
|
|
343
351
|
else if (g.includes("status") || g.includes("check") || g.includes("overview")) {
|
|
344
|
-
steps.push({ step: 1, command: "perp
|
|
352
|
+
steps.push({ step: 1, command: "perp portfolio --json", description: "Balances + positions + risk" }, { step: 2, command: "perp account positions --json", description: "Detailed positions" }, { step: 3, command: "perp account orders --json", description: "Open orders" });
|
|
345
353
|
}
|
|
346
354
|
else if (g.includes("deposit")) {
|
|
347
355
|
const amount = extractNumber(g) || "100";
|
|
@@ -357,7 +365,7 @@ export function registerAgentCommands(program, getAdapter, isJson) {
|
|
|
357
365
|
}
|
|
358
366
|
}
|
|
359
367
|
else {
|
|
360
|
-
steps.push({ step: 1, command: "perp agent capabilities", description: "List all available capabilities" }, { step: 2, command: "perp
|
|
368
|
+
steps.push({ step: 1, command: "perp agent capabilities", description: "List all available capabilities" }, { step: 2, command: "perp portfolio --json", description: "Check account status" });
|
|
361
369
|
}
|
|
362
370
|
printJson(jsonOk({
|
|
363
371
|
goal,
|
|
@@ -4,12 +4,14 @@ import { readExecutionLog, logExecution } from "../../execution-log.js";
|
|
|
4
4
|
import { computeAnnualSpread } from "../../funding.js";
|
|
5
5
|
import { fetchPacificaPrices, fetchHyperliquidMeta, fetchLighterOrderBookDetails, fetchLighterFundingRates, } from "../../shared-api.js";
|
|
6
6
|
import { computeBasisRisk } from "../../arb-utils.js";
|
|
7
|
+
import { smartOrder } from "../../smart-order.js";
|
|
8
|
+
import { removePosition as persistRemovePosition } from "../../arb-state.js";
|
|
7
9
|
import { fetchAllBalances, computeRebalancePlan } from "../../rebalance.js";
|
|
8
10
|
import { EXCHANGE_TO_CHAIN, getBestQuote } from "../../bridge-engine.js";
|
|
9
11
|
import { computeEnhancedStats } from "../../arb-history-stats.js";
|
|
10
12
|
// ── Helpers ──
|
|
13
|
+
import { DEFAULT_TAKER_FEE as TAKER_FEE } from "../../constants.js";
|
|
11
14
|
const EXCHANGES = ["hyperliquid", "lighter", "pacifica"];
|
|
12
|
-
const TAKER_FEE = 0.00035; // ~0.035% typical taker fee
|
|
13
15
|
function formatDuration(ms) {
|
|
14
16
|
const hours = Math.floor(ms / (1000 * 60 * 60));
|
|
15
17
|
const days = Math.floor(hours / 24);
|
|
@@ -165,6 +167,16 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
|
|
|
165
167
|
// Net PnL
|
|
166
168
|
const totalUpnl = longPos.unrealizedPnl + shortPos.unrealizedPnl;
|
|
167
169
|
const netPnl = totalUpnl + estimatedFundingIncome - totalFees;
|
|
170
|
+
// Daily funding estimate from current spread
|
|
171
|
+
const dailyFundingEstimate = currentSpread
|
|
172
|
+
? (currentSpread / 100) / 365 * avgNotional
|
|
173
|
+
: 0;
|
|
174
|
+
// Breakeven analysis
|
|
175
|
+
const totalCosts = totalFees; // entry+exit fees
|
|
176
|
+
const remainingToBreakeven = totalCosts - estimatedFundingIncome - totalUpnl;
|
|
177
|
+
const daysToBreakeven = dailyFundingEstimate > 0 && remainingToBreakeven > 0
|
|
178
|
+
? remainingToBreakeven / dailyFundingEstimate
|
|
179
|
+
: remainingToBreakeven <= 0 ? 0 : null;
|
|
168
180
|
arbPairs.push({
|
|
169
181
|
symbol,
|
|
170
182
|
longExchange: longPos.exchange,
|
|
@@ -195,12 +207,33 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
|
|
|
195
207
|
estimatedFees: totalFees,
|
|
196
208
|
unrealizedPnl: totalUpnl,
|
|
197
209
|
netPnl,
|
|
210
|
+
dailyFundingEstimate,
|
|
211
|
+
breakeven: {
|
|
212
|
+
daysToBreakeven,
|
|
213
|
+
dailyIncome: dailyFundingEstimate,
|
|
214
|
+
totalCosts,
|
|
215
|
+
remainingToBreakeven: Math.max(0, remainingToBreakeven),
|
|
216
|
+
},
|
|
198
217
|
});
|
|
199
218
|
}
|
|
200
219
|
}
|
|
201
220
|
}
|
|
202
221
|
if (isJson()) {
|
|
203
|
-
|
|
222
|
+
const totalNotionalJ = arbPairs.reduce((s, p) => s + (p.longPosition.notionalUsd + p.shortPosition.notionalUsd) / 2, 0);
|
|
223
|
+
const totalDailyJ = arbPairs.reduce((s, p) => s + p.dailyFundingEstimate, 0);
|
|
224
|
+
return printJson(jsonOk({
|
|
225
|
+
pairs: arbPairs,
|
|
226
|
+
aggregate: {
|
|
227
|
+
positions: arbPairs.length,
|
|
228
|
+
totalNotionalUsd: totalNotionalJ,
|
|
229
|
+
unrealizedPnl: arbPairs.reduce((s, p) => s + p.unrealizedPnl, 0),
|
|
230
|
+
estimatedFunding: arbPairs.reduce((s, p) => s + p.estimatedFundingIncome, 0),
|
|
231
|
+
estimatedFees: arbPairs.reduce((s, p) => s + p.estimatedFees, 0),
|
|
232
|
+
netPnl: arbPairs.reduce((s, p) => s + p.netPnl, 0),
|
|
233
|
+
dailyFundingEstimate: totalDailyJ,
|
|
234
|
+
annualizedApr: totalNotionalJ > 0 ? (totalDailyJ * 365 / totalNotionalJ) * 100 : 0,
|
|
235
|
+
},
|
|
236
|
+
}));
|
|
204
237
|
}
|
|
205
238
|
if (arbPairs.length === 0) {
|
|
206
239
|
console.log(chalk.gray(" No open arb positions found.\n"));
|
|
@@ -240,12 +273,20 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
|
|
|
240
273
|
const totalFunding = arbPairs.reduce((s, p) => s + p.estimatedFundingIncome, 0);
|
|
241
274
|
const totalFees = arbPairs.reduce((s, p) => s + p.estimatedFees, 0);
|
|
242
275
|
const totalNet = arbPairs.reduce((s, p) => s + p.netPnl, 0);
|
|
276
|
+
const totalDailyFunding = arbPairs.reduce((s, p) => s + p.dailyFundingEstimate, 0);
|
|
277
|
+
const totalNotional = arbPairs.reduce((s, p) => s + (p.longPosition.notionalUsd + p.shortPosition.notionalUsd) / 2, 0);
|
|
278
|
+
const portfolioApr = totalNotional > 0 ? (totalDailyFunding * 365 / totalNotional) * 100 : 0;
|
|
243
279
|
console.log(chalk.white.bold("\n Summary"));
|
|
244
280
|
console.log(` Positions: ${arbPairs.length}`);
|
|
281
|
+
console.log(` Total Notional: $${formatUsd(totalNotional)}`);
|
|
245
282
|
console.log(` Unrealized PnL: ${formatPnl(totalUpnl)}`);
|
|
246
283
|
console.log(` Est. Funding: ${chalk.yellow(`$${totalFunding.toFixed(4)}`)}`);
|
|
247
284
|
console.log(` Est. Fees: ${chalk.red(`-$${totalFees.toFixed(4)}`)}`);
|
|
248
285
|
console.log(` Net PnL: ${formatPnl(totalNet)}`);
|
|
286
|
+
console.log(` Daily Income: ${chalk.cyan(`$${totalDailyFunding.toFixed(4)}/day`)}`);
|
|
287
|
+
if (portfolioApr > 0) {
|
|
288
|
+
console.log(` Portfolio APR: ${chalk.cyan(`${portfolioApr.toFixed(1)}%`)}`);
|
|
289
|
+
}
|
|
249
290
|
console.log(chalk.gray(` (Fees assume ${(TAKER_FEE * 100).toFixed(3)}% taker for entry + exit.)\n`));
|
|
250
291
|
});
|
|
251
292
|
// ── arb close ──
|
|
@@ -254,6 +295,7 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
|
|
|
254
295
|
.description("Manually close an arb position on both exchanges")
|
|
255
296
|
.option("--dry-run", "Show what would happen without executing")
|
|
256
297
|
.option("--pair <pair>", "Specify arb pair as longExchange:shortExchange (e.g. pacifica:hyperliquid)")
|
|
298
|
+
.option("--smart", "Smart execution: IOC limit at best bid/ask + 1 tick (reduces slippage)")
|
|
257
299
|
.action(async (symbol, opts) => {
|
|
258
300
|
const sym = symbol.toUpperCase();
|
|
259
301
|
const dryRun = !!opts.dryRun || process.argv.includes("--dry-run");
|
|
@@ -389,44 +431,78 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
|
|
|
389
431
|
console.log(chalk.yellow(` BUY ${shortPos.size} ${shortPos.rawSymbol} on ${shortPos.exchange} (close short)\n`));
|
|
390
432
|
return;
|
|
391
433
|
}
|
|
392
|
-
// Execute close on both exchanges
|
|
434
|
+
// Execute close on both exchanges with retry
|
|
435
|
+
const MAX_RETRIES = 2;
|
|
393
436
|
const results = [];
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
(
|
|
437
|
+
async function closeWithRetry(exchange, rawSymbol, side, size, label) {
|
|
438
|
+
let lastError = "";
|
|
439
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
397
440
|
try {
|
|
398
|
-
const adapter = await getAdapterForExchange(
|
|
399
|
-
|
|
400
|
-
|
|
441
|
+
const adapter = await getAdapterForExchange(exchange);
|
|
442
|
+
if (opts.smart) {
|
|
443
|
+
await smartOrder(adapter, rawSymbol, side, String(size), { reduceOnly: true });
|
|
444
|
+
}
|
|
445
|
+
else {
|
|
446
|
+
await adapter.marketOrder(rawSymbol, side, String(size));
|
|
447
|
+
}
|
|
448
|
+
results.push({ exchange, action: `${side} (close ${label})`, status: "success", retries: attempt });
|
|
401
449
|
if (!isJson())
|
|
402
|
-
console.log(chalk.green(` Closed
|
|
450
|
+
console.log(chalk.green(` Closed ${label} on ${exchange}: ${side.toUpperCase()} ${size} ${sym}${opts.smart ? " (smart)" : ""}${attempt > 0 ? ` (retry ${attempt})` : ""}`));
|
|
451
|
+
return;
|
|
403
452
|
}
|
|
404
453
|
catch (err) {
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
454
|
+
lastError = err instanceof Error ? err.message : String(err);
|
|
455
|
+
if (attempt < MAX_RETRIES) {
|
|
456
|
+
if (!isJson())
|
|
457
|
+
console.log(chalk.yellow(` Retry ${attempt + 1}/${MAX_RETRIES} for ${label} on ${exchange}...`));
|
|
458
|
+
await new Promise(r => setTimeout(r, 1000 * (attempt + 1)));
|
|
459
|
+
}
|
|
409
460
|
}
|
|
410
|
-
}
|
|
411
|
-
(
|
|
461
|
+
}
|
|
462
|
+
results.push({ exchange, action: `${side} (close ${label})`, status: "failed", error: lastError, retries: MAX_RETRIES });
|
|
463
|
+
if (!isJson())
|
|
464
|
+
console.error(chalk.red(` Failed to close ${label} on ${exchange}: ${lastError}`));
|
|
465
|
+
}
|
|
466
|
+
// Close both legs concurrently
|
|
467
|
+
await Promise.all([
|
|
468
|
+
closeWithRetry(longPos.exchange, longPos.rawSymbol, "sell", longPos.size, "long"),
|
|
469
|
+
closeWithRetry(shortPos.exchange, shortPos.rawSymbol, "buy", shortPos.size, "short"),
|
|
470
|
+
]);
|
|
471
|
+
const allSuccess = results.every(r => r.status === "success");
|
|
472
|
+
const anySuccess = results.some(r => r.status === "success");
|
|
473
|
+
// Verify positions are actually closed
|
|
474
|
+
let verification;
|
|
475
|
+
if (anySuccess) {
|
|
476
|
+
verification = [];
|
|
477
|
+
for (const r of results) {
|
|
478
|
+
if (r.status !== "success")
|
|
479
|
+
continue;
|
|
412
480
|
try {
|
|
413
|
-
const adapter = await getAdapterForExchange(
|
|
414
|
-
await adapter.
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
481
|
+
const adapter = await getAdapterForExchange(r.exchange);
|
|
482
|
+
const positions = await adapter.getPositions();
|
|
483
|
+
const remaining = positions.find(p => p.symbol.replace("-PERP", "").toUpperCase() === sym);
|
|
484
|
+
verification.push({
|
|
485
|
+
exchange: r.exchange,
|
|
486
|
+
remainingSize: remaining ? Math.abs(Number(remaining.size)) : 0,
|
|
487
|
+
closed: !remaining || Math.abs(Number(remaining.size)) < 0.0001,
|
|
488
|
+
});
|
|
418
489
|
}
|
|
419
|
-
catch
|
|
420
|
-
|
|
421
|
-
results.push({ exchange: shortPos.exchange, action: "buy (close short)", status: "failed", error: msg });
|
|
422
|
-
if (!isJson())
|
|
423
|
-
console.error(chalk.red(` Failed to close short on ${shortPos.exchange}: ${msg}`));
|
|
490
|
+
catch {
|
|
491
|
+
verification.push({ exchange: r.exchange, remainingSize: -1, closed: false });
|
|
424
492
|
}
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
493
|
+
}
|
|
494
|
+
const notClosed = verification.filter(v => !v.closed);
|
|
495
|
+
if (notClosed.length > 0 && !isJson()) {
|
|
496
|
+
console.log(chalk.yellow(`\n Warning: Position may not be fully closed on: ${notClosed.map(v => v.exchange).join(", ")}`));
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
// Remove from persisted daemon state (so daemon doesn't try to re-close)
|
|
500
|
+
if (allSuccess) {
|
|
501
|
+
try {
|
|
502
|
+
persistRemovePosition(sym);
|
|
503
|
+
}
|
|
504
|
+
catch { /* state may not exist */ }
|
|
505
|
+
}
|
|
430
506
|
// Log to execution log
|
|
431
507
|
const arbPairId = `${sym}:${longPos.exchange}:${shortPos.exchange}`;
|
|
432
508
|
logExecution({
|
|
@@ -459,6 +535,7 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
|
|
|
459
535
|
estimatedFees: totalFees,
|
|
460
536
|
netPnl,
|
|
461
537
|
results,
|
|
538
|
+
verification,
|
|
462
539
|
}));
|
|
463
540
|
}
|
|
464
541
|
if (!allSuccess && anySuccess) {
|
|
@@ -918,4 +995,184 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
|
|
|
918
995
|
console.log(chalk.yellow("\n To execute, use: perp rebalance execute --auto-bridge\n"));
|
|
919
996
|
}
|
|
920
997
|
});
|
|
998
|
+
// ── arb funding-earned ──
|
|
999
|
+
arb
|
|
1000
|
+
.command("funding-earned")
|
|
1001
|
+
.description("Actual funding payments received/paid for arb positions")
|
|
1002
|
+
.option("--period <days>", "Number of days to look back", "7")
|
|
1003
|
+
.option("--symbol <symbol>", "Filter by symbol")
|
|
1004
|
+
.action(async (opts) => {
|
|
1005
|
+
const periodDays = parseInt(opts.period);
|
|
1006
|
+
const filterSym = opts.symbol?.toUpperCase();
|
|
1007
|
+
if (!isJson())
|
|
1008
|
+
console.log(chalk.cyan(`\n Fetching funding payments (last ${periodDays} days)...\n`));
|
|
1009
|
+
// Fetch positions to identify arb pairs
|
|
1010
|
+
const allPositions = [];
|
|
1011
|
+
for (const exName of EXCHANGES) {
|
|
1012
|
+
try {
|
|
1013
|
+
const adapter = await getAdapterForExchange(exName);
|
|
1014
|
+
const positions = await adapter.getPositions();
|
|
1015
|
+
for (const p of positions) {
|
|
1016
|
+
const normalized = p.symbol.replace("-PERP", "").toUpperCase();
|
|
1017
|
+
allPositions.push({
|
|
1018
|
+
exchange: exName,
|
|
1019
|
+
symbol: normalized,
|
|
1020
|
+
side: p.side,
|
|
1021
|
+
size: Math.abs(Number(p.size)),
|
|
1022
|
+
markPrice: Number(p.markPrice),
|
|
1023
|
+
});
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
catch {
|
|
1027
|
+
// exchange not configured
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
// Identify arb pairs (long on one exchange, short on another)
|
|
1031
|
+
const arbPairKeys = new Set();
|
|
1032
|
+
const arbPairInfo = new Map();
|
|
1033
|
+
const longs = allPositions.filter(p => p.side === "long");
|
|
1034
|
+
const shorts = allPositions.filter(p => p.side === "short");
|
|
1035
|
+
for (const l of longs) {
|
|
1036
|
+
for (const s of shorts) {
|
|
1037
|
+
if (l.exchange !== s.exchange && l.symbol === s.symbol) {
|
|
1038
|
+
const key = l.symbol;
|
|
1039
|
+
arbPairKeys.add(key);
|
|
1040
|
+
arbPairInfo.set(key, {
|
|
1041
|
+
longExchange: l.exchange,
|
|
1042
|
+
shortExchange: s.exchange,
|
|
1043
|
+
notionalUsd: (l.size * l.markPrice + s.size * s.markPrice) / 2,
|
|
1044
|
+
});
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
const allPayments = [];
|
|
1049
|
+
const cutoff = Date.now() - periodDays * 24 * 60 * 60 * 1000;
|
|
1050
|
+
for (const exName of EXCHANGES) {
|
|
1051
|
+
try {
|
|
1052
|
+
const adapter = await getAdapterForExchange(exName);
|
|
1053
|
+
const payments = await adapter.getFundingPayments(200);
|
|
1054
|
+
for (const p of payments) {
|
|
1055
|
+
if (p.time < cutoff)
|
|
1056
|
+
continue;
|
|
1057
|
+
const sym = p.symbol.replace("-PERP", "").toUpperCase();
|
|
1058
|
+
if (filterSym && sym !== filterSym)
|
|
1059
|
+
continue;
|
|
1060
|
+
allPayments.push({
|
|
1061
|
+
time: p.time,
|
|
1062
|
+
exchange: exName,
|
|
1063
|
+
symbol: sym,
|
|
1064
|
+
payment: typeof p.payment === "string" ? parseFloat(p.payment) : Number(p.payment),
|
|
1065
|
+
});
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
catch {
|
|
1069
|
+
// exchange not configured or no payments
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
// Group by symbol, then by exchange
|
|
1073
|
+
const bySymbol = new Map();
|
|
1074
|
+
for (const p of allPayments) {
|
|
1075
|
+
if (!bySymbol.has(p.symbol))
|
|
1076
|
+
bySymbol.set(p.symbol, []);
|
|
1077
|
+
bySymbol.get(p.symbol).push(p);
|
|
1078
|
+
}
|
|
1079
|
+
const pairs = [];
|
|
1080
|
+
for (const [symbol, payments] of bySymbol) {
|
|
1081
|
+
const isArb = arbPairKeys.has(symbol);
|
|
1082
|
+
const pairInfo = arbPairInfo.get(symbol);
|
|
1083
|
+
// Group by exchange
|
|
1084
|
+
const byEx = new Map();
|
|
1085
|
+
for (const p of payments) {
|
|
1086
|
+
if (!byEx.has(p.exchange))
|
|
1087
|
+
byEx.set(p.exchange, []);
|
|
1088
|
+
byEx.get(p.exchange).push(p);
|
|
1089
|
+
}
|
|
1090
|
+
const exchangeSummaries = [];
|
|
1091
|
+
let totalReceived = 0;
|
|
1092
|
+
let totalPaid = 0;
|
|
1093
|
+
for (const [exchange, exPayments] of byEx) {
|
|
1094
|
+
const received = exPayments.filter(p => p.payment > 0).reduce((s, p) => s + p.payment, 0);
|
|
1095
|
+
const paid = exPayments.filter(p => p.payment < 0).reduce((s, p) => s + Math.abs(p.payment), 0);
|
|
1096
|
+
const net = received - paid;
|
|
1097
|
+
totalReceived += received;
|
|
1098
|
+
totalPaid += paid;
|
|
1099
|
+
exchangeSummaries.push({ exchange, totalReceived: received, totalPaid: paid, net, payments: exPayments.length });
|
|
1100
|
+
}
|
|
1101
|
+
const netEarned = totalReceived - totalPaid;
|
|
1102
|
+
const dailyAvg = periodDays > 0 ? netEarned / periodDays : 0;
|
|
1103
|
+
const notionalUsd = pairInfo?.notionalUsd || 0;
|
|
1104
|
+
const annualizedApr = notionalUsd > 0 ? (dailyAvg * 365 / notionalUsd) * 100 : 0;
|
|
1105
|
+
pairs.push({
|
|
1106
|
+
symbol,
|
|
1107
|
+
isArbPair: isArb,
|
|
1108
|
+
longExchange: pairInfo?.longExchange ?? null,
|
|
1109
|
+
shortExchange: pairInfo?.shortExchange ?? null,
|
|
1110
|
+
notionalUsd,
|
|
1111
|
+
byExchange: exchangeSummaries,
|
|
1112
|
+
totalReceived,
|
|
1113
|
+
totalPaid,
|
|
1114
|
+
netEarned,
|
|
1115
|
+
dailyAvg,
|
|
1116
|
+
annualizedApr,
|
|
1117
|
+
payments: payments.length,
|
|
1118
|
+
});
|
|
1119
|
+
}
|
|
1120
|
+
// Sort: arb pairs first, then by netEarned
|
|
1121
|
+
pairs.sort((a, b) => {
|
|
1122
|
+
if (a.isArbPair !== b.isArbPair)
|
|
1123
|
+
return a.isArbPair ? -1 : 1;
|
|
1124
|
+
return b.netEarned - a.netEarned;
|
|
1125
|
+
});
|
|
1126
|
+
// Totals
|
|
1127
|
+
const totals = {
|
|
1128
|
+
totalReceived: pairs.reduce((s, p) => s + p.totalReceived, 0),
|
|
1129
|
+
totalPaid: pairs.reduce((s, p) => s + p.totalPaid, 0),
|
|
1130
|
+
netEarned: pairs.reduce((s, p) => s + p.netEarned, 0),
|
|
1131
|
+
dailyAvg: pairs.reduce((s, p) => s + p.dailyAvg, 0),
|
|
1132
|
+
totalNotional: pairs.filter(p => p.isArbPair).reduce((s, p) => s + p.notionalUsd, 0),
|
|
1133
|
+
arbPairs: pairs.filter(p => p.isArbPair).length,
|
|
1134
|
+
period: periodDays,
|
|
1135
|
+
};
|
|
1136
|
+
const totalArbNotional = totals.totalNotional;
|
|
1137
|
+
const portfolioApr = totalArbNotional > 0
|
|
1138
|
+
? (totals.dailyAvg * 365 / totalArbNotional) * 100
|
|
1139
|
+
: 0;
|
|
1140
|
+
if (isJson()) {
|
|
1141
|
+
return printJson(jsonOk({
|
|
1142
|
+
pairs,
|
|
1143
|
+
totals: { ...totals, annualizedApr: portfolioApr },
|
|
1144
|
+
}));
|
|
1145
|
+
}
|
|
1146
|
+
if (pairs.length === 0) {
|
|
1147
|
+
console.log(chalk.gray(" No funding payments found.\n"));
|
|
1148
|
+
return;
|
|
1149
|
+
}
|
|
1150
|
+
console.log(chalk.cyan.bold(" Funding Payments Earned\n"));
|
|
1151
|
+
const rows = pairs.map(p => {
|
|
1152
|
+
const arbTag = p.isArbPair ? chalk.green("ARB") : chalk.gray("---");
|
|
1153
|
+
const exchanges = p.byExchange.map(e => e.exchange.slice(0, 3).toUpperCase()).join("+");
|
|
1154
|
+
return [
|
|
1155
|
+
chalk.white.bold(p.symbol),
|
|
1156
|
+
arbTag,
|
|
1157
|
+
exchanges,
|
|
1158
|
+
chalk.green(`$${p.totalReceived.toFixed(4)}`),
|
|
1159
|
+
chalk.red(`-$${p.totalPaid.toFixed(4)}`),
|
|
1160
|
+
formatPnl(p.netEarned),
|
|
1161
|
+
`$${p.dailyAvg.toFixed(4)}/d`,
|
|
1162
|
+
p.annualizedApr > 0 ? `${p.annualizedApr.toFixed(1)}%` : "-",
|
|
1163
|
+
String(p.payments),
|
|
1164
|
+
];
|
|
1165
|
+
});
|
|
1166
|
+
console.log(makeTable(["Symbol", "Type", "Exchanges", "Received", "Paid", "Net", "Daily Avg", "APR", "#"], rows));
|
|
1167
|
+
console.log(chalk.white.bold("\n Totals"));
|
|
1168
|
+
console.log(` Period: ${periodDays} days`);
|
|
1169
|
+
console.log(` Received: ${chalk.green(`$${totals.totalReceived.toFixed(4)}`)}`);
|
|
1170
|
+
console.log(` Paid: ${chalk.red(`-$${totals.totalPaid.toFixed(4)}`)}`);
|
|
1171
|
+
console.log(` Net Earned: ${formatPnl(totals.netEarned)}`);
|
|
1172
|
+
console.log(` Daily Avg: $${totals.dailyAvg.toFixed(4)}/day`);
|
|
1173
|
+
if (portfolioApr > 0) {
|
|
1174
|
+
console.log(` Portfolio APR: ${chalk.cyan(`${portfolioApr.toFixed(1)}%`)} (on $${formatUsd(totalArbNotional)} arb notional)`);
|
|
1175
|
+
}
|
|
1176
|
+
console.log();
|
|
1177
|
+
});
|
|
921
1178
|
}
|