perp-cli 0.14.1 → 0.14.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/dist/arb/sizing.js +29 -7
- package/dist/bot/conditions.d.ts +5 -1
- package/dist/bot/conditions.js +16 -11
- package/dist/bot/engine.d.ts +12 -0
- package/dist/bot/engine.js +55 -44
- package/dist/bot/strategies/claude-agent.js +2 -2
- package/dist/bot/strategies/funding-arb-strategy.js +16 -20
- package/dist/bot/strategies/funding-arb-utils.d.ts +19 -0
- package/dist/bot/strategies/funding-arb-utils.js +44 -0
- package/dist/bot/strategies/funding-auto.js +61 -37
- package/dist/bot/strategies/grid-strategy.d.ts +2 -0
- package/dist/bot/strategies/grid-strategy.js +21 -2
- package/dist/bot/strategies/spot-perp-arb-strategy.js +28 -20
- package/dist/bot/tui/BotDashboard.d.ts +13 -2
- package/dist/bot/tui/BotDashboard.js +8 -3
- package/dist/bridge-engine.d.ts +2 -0
- package/dist/bridge-engine.js +68 -31
- package/dist/commands/account.js +2 -2
- package/dist/commands/arb/index.d.ts +20 -0
- package/dist/commands/arb/index.js +121 -36
- package/dist/commands/arb-auto.d.ts +36 -0
- package/dist/commands/arb-auto.js +202 -76
- package/dist/commands/arb.js +51 -35
- package/dist/commands/bot.d.ts +6 -0
- package/dist/commands/bot.js +55 -45
- package/dist/commands/funds-onchain.js +6 -5
- package/dist/commands/funds.js +18 -5
- package/dist/commands/history.js +135 -53
- package/dist/commands/portfolio.d.ts +5 -0
- package/dist/commands/portfolio.js +58 -20
- package/dist/commands/rebalance.js +46 -10
- package/dist/commands/settings.js +2 -1
- package/dist/commands/trade.js +8 -4
- package/dist/commands/wallet.js +1 -1
- package/dist/cross-chain-margin.d.ts +29 -2
- package/dist/cross-chain-margin.js +30 -4
- package/dist/dashboard/server.d.ts +45 -8
- package/dist/dashboard/server.js +113 -42
- package/dist/dashboard/ui.js +46 -27
- package/dist/dashboard/ws-feeds.d.ts +25 -3
- package/dist/dashboard/ws-feeds.js +219 -82
- package/dist/dex-asset-map.d.ts +23 -4
- package/dist/dex-asset-map.js +39 -13
- package/dist/exchanges/arcus/adapter.js +33 -6
- package/dist/exchanges/arcus/public.d.ts +6 -0
- package/dist/exchanges/arcus/public.js +12 -4
- package/dist/exchanges/aster.d.ts +19 -7
- package/dist/exchanges/aster.js +79 -46
- package/dist/exchanges/hyperliquid.d.ts +30 -0
- package/dist/exchanges/hyperliquid.js +159 -34
- package/dist/exchanges/lighter.d.ts +1 -1
- package/dist/exchanges/lighter.js +11 -9
- package/dist/exchanges/ondo/funds.d.ts +0 -1
- package/dist/exchanges/pacifica.d.ts +7 -0
- package/dist/exchanges/pacifica.js +52 -26
- package/dist/rebalance.d.ts +20 -1
- package/dist/rebalance.js +31 -5
- package/dist/strategies/funding-arb.js +38 -7
- package/package.json +1 -1
- package/skills/perp-cli/SKILL.md +2 -2
- package/skills/perp-cli/references/commands.md +5 -3
package/dist/commands/bot.js
CHANGED
|
@@ -162,17 +162,10 @@ export function registerStrategyCommands(program, getAdapter, getAdapterFor, isJ
|
|
|
162
162
|
.option("--background", "Run in background (tmux)")
|
|
163
163
|
.option("--headless", "Run without TUI dashboard")
|
|
164
164
|
.option("--job-id <id>", "Job ID")
|
|
165
|
-
.action(async (opts) => {
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
try {
|
|
170
|
-
adapters.set(name, await getAdapterFor(name));
|
|
171
|
-
}
|
|
172
|
-
catch {
|
|
173
|
-
// skip unavailable
|
|
174
|
-
}
|
|
175
|
-
}
|
|
165
|
+
.action(async (opts, cmd) => {
|
|
166
|
+
const adapters = await loadBotVenueAdapters(opts.exchanges.split(",").map(e => e.trim()), cmd.getOptionValueSource("exchanges") === "cli", getAdapterFor);
|
|
167
|
+
// The strategy scans only the venues that loaded.
|
|
168
|
+
const exchangeNames = [...adapters.keys()];
|
|
176
169
|
if (adapters.size < 2) {
|
|
177
170
|
console.error(chalk.red("\n Need at least 2 exchanges for arbitrage.\n"));
|
|
178
171
|
return;
|
|
@@ -207,7 +200,8 @@ export function registerStrategyCommands(program, getAdapter, getAdapterFor, isJ
|
|
|
207
200
|
`strategy`, `quick-arb`,
|
|
208
201
|
`--min-spread`, opts.minSpread, `--close-spread`, opts.closeSpread,
|
|
209
202
|
`--size`, opts.size, `--max-positions`, opts.maxPositions,
|
|
210
|
-
|
|
203
|
+
// The venues the parent loaded (a skipped default venue is not re-tried as "explicit").
|
|
204
|
+
`--exchanges`, exchangeNames.join(","), `--interval`, opts.interval,
|
|
211
205
|
`--max-drawdown`, opts.maxDrawdown,
|
|
212
206
|
];
|
|
213
207
|
const job = startJob({
|
|
@@ -241,17 +235,10 @@ export function registerStrategyCommands(program, getAdapter, getAdapterFor, isJ
|
|
|
241
235
|
.option("--background", "Run in background (tmux)")
|
|
242
236
|
.option("--headless", "Run without TUI dashboard")
|
|
243
237
|
.option("--job-id <id>", "Job ID")
|
|
244
|
-
.action(async (opts) => {
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
try {
|
|
249
|
-
adapters.set(name, await getAdapterFor(name));
|
|
250
|
-
}
|
|
251
|
-
catch {
|
|
252
|
-
// skip unavailable
|
|
253
|
-
}
|
|
254
|
-
}
|
|
238
|
+
.action(async (opts, cmd) => {
|
|
239
|
+
const adapters = await loadBotVenueAdapters(opts.exchanges.split(",").map(e => e.trim()), cmd.getOptionValueSource("exchanges") === "cli", getAdapterFor);
|
|
240
|
+
// The strategy trades only the venues that loaded.
|
|
241
|
+
const exchangeNames = [...adapters.keys()];
|
|
255
242
|
if (adapters.size < 1) {
|
|
256
243
|
console.error(chalk.red("\n Need at least 1 exchange for delta-neutral.\n"));
|
|
257
244
|
return;
|
|
@@ -287,7 +274,7 @@ export function registerStrategyCommands(program, getAdapter, getAdapterFor, isJ
|
|
|
287
274
|
`strategy`, `delta-neutral`,
|
|
288
275
|
`--min-rate`, opts.minRate, `--close-rate`, opts.closeRate,
|
|
289
276
|
`--size`, opts.size, `--max-positions`, opts.maxPositions,
|
|
290
|
-
`--leverage`, opts.leverage, `--exchanges`,
|
|
277
|
+
`--leverage`, opts.leverage, `--exchanges`, exchangeNames.join(","),
|
|
291
278
|
`--interval`, opts.interval, `--max-drawdown`, opts.maxDrawdown,
|
|
292
279
|
];
|
|
293
280
|
const job = startJob({
|
|
@@ -355,15 +342,9 @@ export function registerStrategyCommands(program, getAdapter, getAdapterFor, isJ
|
|
|
355
342
|
const built = preset.buildConfig("multi", sym);
|
|
356
343
|
const config = opts.size !== undefined ? withPresetSize(built, opts.size) : built;
|
|
357
344
|
const arbStrategy = config.strategy;
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
adapters.set(exName, await getAdapterFor(exName));
|
|
362
|
-
}
|
|
363
|
-
catch {
|
|
364
|
-
// skip unavailable
|
|
365
|
-
}
|
|
366
|
-
}
|
|
345
|
+
// The preset's venue list is a default (not user-passed): a failing venue is skipped with a warning.
|
|
346
|
+
const adapters = await loadBotVenueAdapters(arbStrategy.exchanges, false, getAdapterFor);
|
|
347
|
+
arbStrategy.exchanges = [...adapters.keys()];
|
|
367
348
|
if (adapters.size < 2) {
|
|
368
349
|
console.error(chalk.red("\n Need at least 2 exchanges for arbitrage.\n"));
|
|
369
350
|
return;
|
|
@@ -756,6 +737,44 @@ export function registerStrategyCommands(program, getAdapter, getAdapterFor, isJ
|
|
|
756
737
|
// Nested scripted plans: `perp strategy plan {validate|execute|example}`
|
|
757
738
|
registerStrategyPlanCommands(bot, getAdapter, isJson);
|
|
758
739
|
}
|
|
740
|
+
/**
|
|
741
|
+
* Adapters for a multi-venue bot. An explicitly passed list (--exchanges on the command line)
|
|
742
|
+
* must load in full: a venue that fails refuses the start with a PerpError naming it. A default
|
|
743
|
+
* list skips a failing venue with a stderr warning naming it and the cause.
|
|
744
|
+
*/
|
|
745
|
+
export async function loadBotVenueAdapters(names, explicit, getAdapterFor) {
|
|
746
|
+
const adapters = new Map();
|
|
747
|
+
for (const name of names) {
|
|
748
|
+
try {
|
|
749
|
+
const a = await getAdapterFor(name);
|
|
750
|
+
// A venue without credentials gets a read-only adapter: it can neither trade nor report
|
|
751
|
+
// the bot's balances, so it is "not configured" — refused when listed explicitly.
|
|
752
|
+
if (a.isReadOnly === true) {
|
|
753
|
+
if (explicit) {
|
|
754
|
+
throw new PerpError("NO_SIGNER_AVAILABLE", `--exchanges venue ${name} has no credentials configured`, {
|
|
755
|
+
exchange: name, remediation: `Set it up (perp setup) or remove it from --exchanges`,
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
process.stderr.write(`[info] ${name} not configured (no credentials) — the bot runs without it\n`);
|
|
759
|
+
continue;
|
|
760
|
+
}
|
|
761
|
+
adapters.set(name, a);
|
|
762
|
+
}
|
|
763
|
+
catch (e) {
|
|
764
|
+
if (e instanceof PerpError && e.structured.code === "NO_SIGNER_AVAILABLE" && explicit)
|
|
765
|
+
throw e;
|
|
766
|
+
const cause = e instanceof Error ? e.message : String(e);
|
|
767
|
+
if (explicit) {
|
|
768
|
+
throw new PerpError(e instanceof PerpError ? e.structured.code : "EXCHANGE_ERROR", `--exchanges venue ${name} could not be initialised: ${cause}`, {
|
|
769
|
+
exchange: name,
|
|
770
|
+
remediation: `Fix ${name}'s setup (credentials / network: perp -e ${name} portfolio), or remove it from --exchanges`,
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
process.stderr.write(`[warn] ${name} unavailable (${cause}) — the bot runs without it\n`);
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
return adapters;
|
|
777
|
+
}
|
|
759
778
|
function makeLog() {
|
|
760
779
|
return (msg) => {
|
|
761
780
|
const ts = new Date().toLocaleTimeString();
|
|
@@ -898,19 +917,10 @@ function registerRunSubcommands(parent, getAdapter, getAdapterFor, isJson) {
|
|
|
898
917
|
.option("--rebalance-threshold <usd>", "Rebalance when available < this ($)", "100")
|
|
899
918
|
.option("--max-drawdown <usd>", "Close all positions if total uPnL exceeds this loss ($)")
|
|
900
919
|
.option("--job-id <id>", "Job ID (set automatically for background jobs)")
|
|
901
|
-
.action(async (opts) => {
|
|
920
|
+
.action(async (opts, cmd) => {
|
|
902
921
|
const exchangeNames = opts.exchanges.split(",").map((e) => e.trim());
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
try {
|
|
906
|
-
const a = await getAdapterFor(name);
|
|
907
|
-
adapters.set(name, a);
|
|
908
|
-
}
|
|
909
|
-
catch {
|
|
910
|
-
if (!isJson())
|
|
911
|
-
console.log(chalk.yellow(` Skipping ${name} (no credentials)`));
|
|
912
|
-
}
|
|
913
|
-
}
|
|
922
|
+
// An explicitly listed venue that fails to load refuses the start; a default one is skipped with a warning.
|
|
923
|
+
const adapters = await loadBotVenueAdapters(exchangeNames, cmd.getOptionValueSource("exchanges") === "cli", getAdapterFor);
|
|
914
924
|
if (adapters.size < 2) {
|
|
915
925
|
console.error(chalk.red("Need at least 2 exchanges for arbitrage. Set credentials for 2+ exchanges."));
|
|
916
926
|
process.exit(1);
|
|
@@ -238,11 +238,12 @@ export function registerOnchainFundsCommands(ctx) {
|
|
|
238
238
|
// Ondo Perps withdrawals are web-app only (the API does not allow them), so an address-book
|
|
239
239
|
// entry made here would serve nothing. The command stays so the refusal is explicit.
|
|
240
240
|
const addressBook = funds.command("address-book").description("Not available: the Ondo Perps withdrawal address book is managed in the web app");
|
|
241
|
-
|
|
242
|
-
|
|
241
|
+
// Optional arguments: a bare `address-book add` gets the same explicit refusal, not a usage error.
|
|
242
|
+
supportsDryRun(addressBook.command("add [venue] [address]"))
|
|
243
|
+
.description("Not available: no venue offers a withdrawal address book over the CLI (Ondo Perps: web app only; refused, also with --dry-run)")
|
|
243
244
|
.option("--label <text>", "Ignored")
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
throw unsupported(
|
|
245
|
+
// Venue-neutral: no venue has a CLI address book, so the arguments are not even resolved.
|
|
246
|
+
.action(async () => {
|
|
247
|
+
throw unsupported("perp-cli", "address-book", "perp-cli has no withdrawal address book for any venue (Ondo Perps withdrawals are web-app only)", "Manage withdrawals and address books in the venue's web app");
|
|
247
248
|
});
|
|
248
249
|
}
|
package/dist/commands/funds.js
CHANGED
|
@@ -94,6 +94,13 @@ export function chainLabel(chain) {
|
|
|
94
94
|
throw new Error(`no display label for chain "${chain}" (add it to CHAIN_LABELS)`);
|
|
95
95
|
return label;
|
|
96
96
|
}
|
|
97
|
+
/** Circle forwarding fee per HL CCTP destination, from HL_CCTP_ROUTES (null = not published). */
|
|
98
|
+
async function hlCctpFeeText() {
|
|
99
|
+
const { HL_CCTP_ROUTES } = await import("../exchanges/hyperliquid.js");
|
|
100
|
+
return Object.entries(HL_CCTP_ROUTES)
|
|
101
|
+
.map(([chain, r]) => `${chain}: ${r.forwardingFeeUsdc === null ? "fee not published" : `${r.forwardingFeeUsdc} USDC`}`)
|
|
102
|
+
.join(", ");
|
|
103
|
+
}
|
|
97
104
|
/** Withdraw asset declared by the venue definition (capabilities.withdrawAssets[0]). */
|
|
98
105
|
function withdrawAssetOf(def) {
|
|
99
106
|
const assets = requireCapabilities(def).withdrawAssets;
|
|
@@ -1345,11 +1352,13 @@ export function registerFundsCommands(program, getAdapter, isJson, getNetwork, g
|
|
|
1345
1352
|
},
|
|
1346
1353
|
withdraw: {
|
|
1347
1354
|
pacifica: { chain: "Solana", token: "USDC", speed: "~10s", fee: "none" },
|
|
1348
|
-
hyperliquid: { chain: "Arbitrum", token: "USDC", speed: "minutes", fee: "
|
|
1355
|
+
hyperliquid: { chain: "Arbitrum (default) / Ethereum / Base", token: "USDC", speed: "minutes (CCTP attestation)", method: "CCTP (sendToEvmWithData)", fee: await hlCctpFeeText(), command: "perp funds withdraw hyperliquid <amount> [--chain arbitrum|ethereum|base]" },
|
|
1349
1356
|
lighter: { chain: "Ethereum L1", token: "USDC", speed: "~12 hours (standard), minutes (fast)", fee: "varies" },
|
|
1350
1357
|
"lighter-rh": { chain: "Robinhood Chain", token: "USDG", command: "perp -e lighter-rh funds withdraw lighter <amount>" },
|
|
1351
1358
|
ondo: { supported: false, note: "the Ondo Perps API does not allow withdrawals — withdraw in the Ondo Perps web app" },
|
|
1352
|
-
qfex:
|
|
1359
|
+
qfex: QFEX_WITHDRAW_ENABLED
|
|
1360
|
+
? { chain: chainLabel(QFEX_WITHDRAW_NETWORK), token: "USDC", command: "perp funds withdraw qfex <amount> --to <allowlisted address>", gate: "settings withdraw.qfex (allowlist, maxPerTx, maxPerDay)", note: "needs a key with the \"Deposit and withdraw\" permission" }
|
|
1361
|
+
: { supported: false, note: `${QFEX_WITHDRAW_DISABLED.reason} — ${QFEX_WITHDRAW_DISABLED.remediation}` },
|
|
1353
1362
|
arcus: { chain: "Robinhood Chain", token: "USDG", command: "perp funds withdraw arcus <amount>" },
|
|
1354
1363
|
aster: { chain: "BNB Chain (default) / Arbitrum", token: "USDT (default) / USDC", command: "perp funds withdraw aster <amount> [--chain bsc|arbitrum] [--asset USDT|USDC]", fee: "POST /fapi/v3/aster/user-withdraw-info (taken from the amount)", requires: "mainnet; API wallet approved with --can-withdraw --ip-whitelist; destination = the account's own address" },
|
|
1355
1364
|
},
|
|
@@ -1390,9 +1399,9 @@ export function registerFundsCommands(program, getAdapter, isJson, getNetwork, g
|
|
|
1390
1399
|
console.log(chalk.white.bold(" Pacifica") + chalk.gray(" → Solana"));
|
|
1391
1400
|
console.log(` Speed: ~10s | Fee: none`);
|
|
1392
1401
|
console.log(` Command: ${chalk.green("perp funds withdraw pacifica <amount>")}`);
|
|
1393
|
-
console.log(chalk.white.bold("\n Hyperliquid") + chalk.gray(" → Arbitrum"));
|
|
1394
|
-
console.log(`
|
|
1395
|
-
console.log(` Command: ${chalk.green("perp
|
|
1402
|
+
console.log(chalk.white.bold("\n Hyperliquid") + chalk.gray(" → Arbitrum (default) / Ethereum / Base over CCTP"));
|
|
1403
|
+
console.log(` Fee: ${await hlCctpFeeText()}`);
|
|
1404
|
+
console.log(` Command: ${chalk.green("perp funds withdraw hyperliquid <amount> [--chain arbitrum|ethereum|base]")}`);
|
|
1396
1405
|
console.log(chalk.white.bold("\n Lighter") + chalk.gray(" → Ethereum L1"));
|
|
1397
1406
|
console.log(` Speed: ~12h (standard) | Fee: varies`);
|
|
1398
1407
|
console.log(` Command: ${chalk.green("perp -e lt funds withdraw lighter <amount>")}`);
|
|
@@ -1402,6 +1411,10 @@ export function registerFundsCommands(program, getAdapter, isJson, getNetwork, g
|
|
|
1402
1411
|
console.log(chalk.gray(" Withdraw in the Ondo Perps web app."));
|
|
1403
1412
|
console.log(chalk.white.bold("\n Arcus") + chalk.gray(" → Robinhood Chain (USDG, to the account owner)"));
|
|
1404
1413
|
console.log(` Command: ${chalk.green("perp funds withdraw arcus <amount>")}`);
|
|
1414
|
+
console.log(chalk.white.bold("\n QFEX") + chalk.gray(QFEX_WITHDRAW_ENABLED ? ` → ${chainLabel(QFEX_WITHDRAW_NETWORK)} (USDC, allowlisted address)` : " — disabled in perp-cli for now"));
|
|
1415
|
+
console.log(QFEX_WITHDRAW_ENABLED
|
|
1416
|
+
? ` Command: ${chalk.green("perp funds withdraw qfex <amount> --to <allowlisted address>")}`
|
|
1417
|
+
: chalk.gray(` ${QFEX_WITHDRAW_DISABLED.remediation}.`));
|
|
1405
1418
|
console.log(chalk.white.bold("\n Aster") + chalk.gray(" → BNB Chain / Arbitrum (USDT, to the account's own address; mainnet only)"));
|
|
1406
1419
|
console.log(` Needs an API wallet approved with --can-withdraw --ip-whitelist; fee from user-withdraw-info`);
|
|
1407
1420
|
console.log(` Command: ${chalk.green("perp funds withdraw aster <amount> [--chain bsc|arbitrum] [--asset USDT|USDC]")}`);
|
package/dist/commands/history.js
CHANGED
|
@@ -359,26 +359,39 @@ function registerAnalyticsSubcommands(parent, getAdapterForExchange, isJson) {
|
|
|
359
359
|
const limit = parseInt(opts.limit);
|
|
360
360
|
const allTrades = [];
|
|
361
361
|
const allFunding = [];
|
|
362
|
+
// A venue whose adapter / trades / funding read failed is listed here and left out of the
|
|
363
|
+
// totals (partial: true) — never counted as 0.
|
|
364
|
+
const errors = {};
|
|
365
|
+
const errText = (e) => (e instanceof Error ? e.message : String(e));
|
|
362
366
|
// Fetch trades + funding in parallel from all exchanges
|
|
363
367
|
await Promise.allSettled(exchanges.map(async (ex) => {
|
|
368
|
+
let adapter;
|
|
364
369
|
try {
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
370
|
+
adapter = await getAdapterForExchange(ex);
|
|
371
|
+
}
|
|
372
|
+
catch (e) {
|
|
373
|
+
errors[`${ex}.adapter`] = errText(e);
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
const [trades, funding] = await Promise.allSettled([adapter.getTradeHistory(limit), adapter.getFundingPayments(limit)]);
|
|
377
|
+
if (trades.status === "fulfilled") {
|
|
378
|
+
for (const t of trades.value) {
|
|
371
379
|
if (sinceMs && t.time < sinceMs)
|
|
372
380
|
continue;
|
|
373
381
|
allTrades.push({ exchange: ex, symbol: t.symbol, side: t.side, price: Number(t.price), size: Number(t.size), fee: Number(t.fee), time: t.time });
|
|
374
382
|
}
|
|
375
|
-
|
|
383
|
+
}
|
|
384
|
+
else
|
|
385
|
+
errors[`${ex}.trades`] = errText(trades.reason);
|
|
386
|
+
if (funding.status === "fulfilled") {
|
|
387
|
+
for (const f of funding.value) {
|
|
376
388
|
if (sinceMs && f.time < sinceMs)
|
|
377
389
|
continue;
|
|
378
390
|
allFunding.push({ exchange: ex, symbol: f.symbol, payment: Number(f.payment), time: f.time });
|
|
379
391
|
}
|
|
380
392
|
}
|
|
381
|
-
|
|
393
|
+
else
|
|
394
|
+
errors[`${ex}.funding`] = errText(funding.reason);
|
|
382
395
|
}));
|
|
383
396
|
// Aggregate by exchange
|
|
384
397
|
const byExchange = new Map();
|
|
@@ -428,6 +441,8 @@ function registerAnalyticsSubcommands(parent, getAdapterForExchange, isJson) {
|
|
|
428
441
|
netPnl,
|
|
429
442
|
byExchange: Object.fromEntries([...byExchange.entries()].map(([ex, d]) => [ex, { ...d, netPnl: d.funding - d.fees }])),
|
|
430
443
|
daily,
|
|
444
|
+
errors,
|
|
445
|
+
partial: Object.keys(errors).length > 0,
|
|
431
446
|
};
|
|
432
447
|
if (isJson())
|
|
433
448
|
return printJson(jsonOk(result));
|
|
@@ -438,6 +453,8 @@ function registerAnalyticsSubcommands(parent, getAdapterForExchange, isJson) {
|
|
|
438
453
|
console.log(` Trading Fees: ${chalk.red(`-$${formatUsd(totalFees)}`)}`);
|
|
439
454
|
console.log(` Funding Income: ${formatPnl(totalFunding)}`);
|
|
440
455
|
console.log(` Net P&L: ${formatPnl(netPnl)}`);
|
|
456
|
+
for (const [k, msg] of Object.entries(errors))
|
|
457
|
+
console.log(chalk.yellow(` ⚠ ${k} unavailable (left out of the totals): ${msg}`));
|
|
441
458
|
if (byExchange.size > 0) {
|
|
442
459
|
console.log(chalk.white.bold("\n By Exchange:"));
|
|
443
460
|
const rows = [...byExchange.entries()].map(([ex, d]) => [
|
|
@@ -480,27 +497,44 @@ function registerAnalyticsSubcommands(parent, getAdapterForExchange, isJson) {
|
|
|
480
497
|
const allFunding = [];
|
|
481
498
|
// Fetch funding payments and current positions in parallel
|
|
482
499
|
const positionNotionals = new Map();
|
|
500
|
+
// A venue whose adapter / funding / positions read failed is listed here; its funding is
|
|
501
|
+
// left out of the totals (partial: true) — never counted as 0.
|
|
502
|
+
const errors = {};
|
|
503
|
+
const errText = (e) => (e instanceof Error ? e.message : String(e));
|
|
483
504
|
await Promise.allSettled(exchanges.map(async (ex) => {
|
|
505
|
+
let adapter;
|
|
484
506
|
try {
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
507
|
+
adapter = await getAdapterForExchange(ex);
|
|
508
|
+
}
|
|
509
|
+
catch (e) {
|
|
510
|
+
errors[`${ex}.adapter`] = errText(e);
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
const [payments, positions] = await Promise.allSettled([
|
|
514
|
+
adapter.getFundingPayments(parseInt(opts.limit)),
|
|
515
|
+
adapter.getPositions(),
|
|
516
|
+
]);
|
|
517
|
+
if (payments.status === "fulfilled") {
|
|
518
|
+
for (const p of payments.value) {
|
|
491
519
|
if (sinceMs && p.time < sinceMs)
|
|
492
520
|
continue;
|
|
493
521
|
if (opts.symbol && !p.symbol.toUpperCase().includes(opts.symbol.toUpperCase()))
|
|
494
522
|
continue;
|
|
495
523
|
allFunding.push({ exchange: ex, symbol: p.symbol, payment: Number(p.payment), time: p.time });
|
|
496
524
|
}
|
|
497
|
-
|
|
525
|
+
}
|
|
526
|
+
else
|
|
527
|
+
errors[`${ex}.funding`] = errText(payments.reason);
|
|
528
|
+
// Positions only feed the annualized rate (null when the notional is unknown).
|
|
529
|
+
if (positions.status === "fulfilled") {
|
|
530
|
+
for (const p of positions.value) {
|
|
498
531
|
if (Number(p.size) > 0) {
|
|
499
532
|
positionNotionals.set(`${ex}:${p.symbol}`, Math.abs(Number(p.size) * Number(p.markPrice)));
|
|
500
533
|
}
|
|
501
534
|
}
|
|
502
535
|
}
|
|
503
|
-
|
|
536
|
+
else
|
|
537
|
+
errors[`${ex}.positions`] = errText(positions.reason);
|
|
504
538
|
}));
|
|
505
539
|
allFunding.sort((a, b) => a.time - b.time);
|
|
506
540
|
// Aggregate by exchange×symbol
|
|
@@ -556,16 +590,19 @@ function registerAnalyticsSubcommands(parent, getAdapterForExchange, isJson) {
|
|
|
556
590
|
const arbRows = arbPositions
|
|
557
591
|
.map(pos => {
|
|
558
592
|
const af = arbFunding.get(pos.symbol);
|
|
559
|
-
|
|
593
|
+
// A leg on a venue whose funding read failed is unknown (null), not 0.
|
|
594
|
+
const longFunding = af && !(`${pos.longExchange}.adapter` in errors || `${pos.longExchange}.funding` in errors) ? af.longFunding : null;
|
|
595
|
+
const shortFunding = af && !(`${pos.shortExchange}.adapter` in errors || `${pos.shortExchange}.funding` in errors) ? af.shortFunding : null;
|
|
596
|
+
if (!af || (longFunding === 0 && shortFunding === 0))
|
|
560
597
|
return null;
|
|
561
598
|
return {
|
|
562
599
|
symbol: pos.symbol,
|
|
563
600
|
mode: pos.mode ?? "perp-perp",
|
|
564
601
|
longExchange: pos.longExchange,
|
|
565
602
|
shortExchange: pos.shortExchange,
|
|
566
|
-
netFunding:
|
|
567
|
-
longFunding
|
|
568
|
-
shortFunding
|
|
603
|
+
netFunding: longFunding !== null && shortFunding !== null ? longFunding + shortFunding : null,
|
|
604
|
+
longFunding,
|
|
605
|
+
shortFunding,
|
|
569
606
|
daysSinceEntry: Math.max(1, Math.round((Date.now() - new Date(pos.entryTime).getTime()) / 86400000)),
|
|
570
607
|
};
|
|
571
608
|
})
|
|
@@ -580,6 +617,8 @@ function registerAnalyticsSubcommands(parent, getAdapterForExchange, isJson) {
|
|
|
580
617
|
byExchange: Object.fromEntries(byExchange),
|
|
581
618
|
arbPositions: arbRows,
|
|
582
619
|
...(opts.daily ? { daily } : {}),
|
|
620
|
+
errors,
|
|
621
|
+
partial: Object.keys(errors).length > 0,
|
|
583
622
|
};
|
|
584
623
|
if (isJson())
|
|
585
624
|
return printJson(jsonOk(result));
|
|
@@ -588,6 +627,8 @@ function registerAnalyticsSubcommands(parent, getAdapterForExchange, isJson) {
|
|
|
588
627
|
console.log(` Total Payments: ${allFunding.length}`);
|
|
589
628
|
console.log(` Net Funding: ${formatPnl(totalFunding)}`);
|
|
590
629
|
console.log(` Period: ~${Math.round(periodDays)} days`);
|
|
630
|
+
for (const [k, msg] of Object.entries(errors))
|
|
631
|
+
console.log(chalk.yellow(` ⚠ ${k} unavailable (left out of the totals): ${msg}`));
|
|
591
632
|
if (exSymRows.length > 0) {
|
|
592
633
|
console.log(chalk.white.bold("\n By Exchange × Symbol:"));
|
|
593
634
|
const rows = exSymRows.map(e => [
|
|
@@ -605,7 +646,7 @@ function registerAnalyticsSubcommands(parent, getAdapterForExchange, isJson) {
|
|
|
605
646
|
chalk.white.bold(a.symbol),
|
|
606
647
|
`${a.longExchange} → ${a.shortExchange}`,
|
|
607
648
|
a.mode,
|
|
608
|
-
formatPnl(a.netFunding),
|
|
649
|
+
a.netFunding !== null ? formatPnl(a.netFunding) : chalk.yellow("unavailable"),
|
|
609
650
|
`${a.daysSinceEntry}d`,
|
|
610
651
|
]);
|
|
611
652
|
console.log(makeTable(["Symbol", "Long → Short", "Mode", "Net Funding", "Held"], rows));
|
|
@@ -650,6 +691,7 @@ function registerAnalyticsSubcommands(parent, getAdapterForExchange, isJson) {
|
|
|
650
691
|
// A venue whose read failed is reported here and left out of the totals — never counted as 0.
|
|
651
692
|
const errors = {};
|
|
652
693
|
const errText = (e) => (e instanceof Error ? e.message : String(e));
|
|
694
|
+
let venuesRead = 0;
|
|
653
695
|
await Promise.allSettled(exchanges.map(async (ex) => {
|
|
654
696
|
let adapter;
|
|
655
697
|
try {
|
|
@@ -663,6 +705,7 @@ function registerAnalyticsSubcommands(parent, getAdapterForExchange, isJson) {
|
|
|
663
705
|
const balance = await adapter.getBalance();
|
|
664
706
|
totalEquity += Number(balance.equity);
|
|
665
707
|
totalUPnl += Number(balance.unrealizedPnl);
|
|
708
|
+
venuesRead++;
|
|
666
709
|
}
|
|
667
710
|
catch (e) {
|
|
668
711
|
errors[`${ex}.balance`] = errText(e);
|
|
@@ -710,6 +753,10 @@ function registerAnalyticsSubcommands(parent, getAdapterForExchange, isJson) {
|
|
|
710
753
|
: "0",
|
|
711
754
|
},
|
|
712
755
|
errors,
|
|
756
|
+
// partial: some venue read failed — portfolio / costs cover only the venues that answered.
|
|
757
|
+
partial: Object.keys(errors).length > 0,
|
|
758
|
+
venuesRead,
|
|
759
|
+
venuesRequested: exchanges.length,
|
|
713
760
|
};
|
|
714
761
|
if (isJson())
|
|
715
762
|
return printJson(jsonOk(report));
|
|
@@ -759,25 +806,49 @@ function registerAnalyticsSubcommands(parent, getAdapterForExchange, isJson) {
|
|
|
759
806
|
// Fetch funding + current positions from all relevant exchanges
|
|
760
807
|
const fundingByExSym = new Map();
|
|
761
808
|
const currentPrices = new Map();
|
|
809
|
+
// A venue whose adapter / funding / positions read failed is keyed here: rows touching it
|
|
810
|
+
// report null for the unknown part (funding / pricePnl), never 0 or the entry price.
|
|
811
|
+
const errors = {};
|
|
812
|
+
const errText = (e) => (e instanceof Error ? e.message : String(e));
|
|
813
|
+
const fundingFailed = new Set();
|
|
762
814
|
await Promise.allSettled([...exchangeSet].map(async (ex) => {
|
|
815
|
+
let adapter;
|
|
763
816
|
try {
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
817
|
+
adapter = await getAdapterForExchange(ex);
|
|
818
|
+
}
|
|
819
|
+
catch (e) {
|
|
820
|
+
errors[`${ex}.adapter`] = errText(e);
|
|
821
|
+
fundingFailed.add(ex);
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
const [funding, livePositions] = await Promise.allSettled([
|
|
825
|
+
adapter.getFundingPayments(limit),
|
|
826
|
+
adapter.getPositions(),
|
|
827
|
+
]);
|
|
828
|
+
if (funding.status === "fulfilled") {
|
|
829
|
+
for (const f of funding.value) {
|
|
770
830
|
if (sinceMs && f.time < sinceMs)
|
|
771
831
|
continue;
|
|
772
832
|
const key = `${ex}:${f.symbol}`;
|
|
773
833
|
fundingByExSym.set(key, (fundingByExSym.get(key) ?? 0) + Number(f.payment));
|
|
774
834
|
}
|
|
775
|
-
|
|
835
|
+
}
|
|
836
|
+
else {
|
|
837
|
+
errors[`${ex}.funding`] = errText(funding.reason);
|
|
838
|
+
fundingFailed.add(ex);
|
|
839
|
+
}
|
|
840
|
+
if (livePositions.status === "fulfilled") {
|
|
841
|
+
for (const p of livePositions.value) {
|
|
776
842
|
currentPrices.set(`${ex}:${p.symbol}`, Number(p.markPrice));
|
|
777
843
|
}
|
|
778
844
|
}
|
|
779
|
-
|
|
845
|
+
else
|
|
846
|
+
errors[`${ex}.positions`] = errText(livePositions.reason);
|
|
780
847
|
}));
|
|
848
|
+
// Funding of a venue that answered: the sum of its payments (none = 0). A failed venue: unknown (null).
|
|
849
|
+
const fundingOf = (ex, sym) => fundingFailed.has(ex) ? null : fundingByExSym.get(`${ex}:${sym}`) ?? 0;
|
|
850
|
+
// Current mark from the live position; none (failed read / no live leg) = unknown (null), never the entry price.
|
|
851
|
+
const priceOf = (ex, sym) => currentPrices.get(`${ex}:${sym}`) ?? null;
|
|
781
852
|
// Entries without a verified entry price (a bot leg the venue reported no fill price for)
|
|
782
853
|
// have no price PnL: listed apart, never priced at a guess.
|
|
783
854
|
const priced = positions.filter((p) => p.entryLongPrice !== null && p.entryShortPrice !== null);
|
|
@@ -785,21 +856,21 @@ function registerAnalyticsSubcommands(parent, getAdapterForExchange, isJson) {
|
|
|
785
856
|
// Build comparison data
|
|
786
857
|
const rows = priced.map(pos => {
|
|
787
858
|
const daysHeld = Math.max(1, Math.round((Date.now() - new Date(pos.entryTime).getTime()) / 86400000));
|
|
788
|
-
const longFunding =
|
|
789
|
-
const shortFunding =
|
|
790
|
-
const netFunding = longFunding + shortFunding;
|
|
791
|
-
// Estimate unrealized PnL from price movement
|
|
792
|
-
const longPrice =
|
|
793
|
-
const shortPrice =
|
|
794
|
-
const
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
const totalPnl = pricePnl + netFunding;
|
|
859
|
+
const longFunding = fundingOf(pos.longExchange, pos.symbol);
|
|
860
|
+
const shortFunding = fundingOf(pos.shortExchange, pos.symbol);
|
|
861
|
+
const netFunding = longFunding !== null && shortFunding !== null ? longFunding + shortFunding : null;
|
|
862
|
+
// Estimate unrealized PnL from price movement (null when either leg's mark is unknown)
|
|
863
|
+
const longPrice = priceOf(pos.longExchange, pos.symbol);
|
|
864
|
+
const shortPrice = priceOf(pos.shortExchange, pos.symbol);
|
|
865
|
+
const pricePnl = longPrice !== null && shortPrice !== null
|
|
866
|
+
? (longPrice - pos.entryLongPrice) * pos.longSize + (pos.entryShortPrice - shortPrice) * pos.shortSize
|
|
867
|
+
: null;
|
|
868
|
+
const totalPnl = pricePnl !== null && netFunding !== null ? pricePnl + netFunding : null;
|
|
798
869
|
// Notional for ROI calculation
|
|
799
870
|
const notional = pos.entryLongPrice * pos.longSize + pos.entryShortPrice * pos.shortSize;
|
|
800
|
-
const roi = notional > 0 ? (totalPnl / notional) * 100 : 0;
|
|
801
|
-
const fundingRoi = notional > 0 ? (netFunding / notional) * 100 : 0;
|
|
802
|
-
const annualizedRoi = daysHeld > 0 ? (fundingRoi / daysHeld) * 365 : 0;
|
|
871
|
+
const roi = totalPnl !== null ? (notional > 0 ? (totalPnl / notional) * 100 : 0) : null;
|
|
872
|
+
const fundingRoi = netFunding !== null ? (notional > 0 ? (netFunding / notional) * 100 : 0) : null;
|
|
873
|
+
const annualizedRoi = fundingRoi !== null ? (daysHeld > 0 ? (fundingRoi / daysHeld) * 365 : 0) : null;
|
|
803
874
|
return {
|
|
804
875
|
symbol: pos.symbol,
|
|
805
876
|
mode: pos.mode ?? "perp-perp",
|
|
@@ -816,14 +887,19 @@ function registerAnalyticsSubcommands(parent, getAdapterForExchange, isJson) {
|
|
|
816
887
|
annualizedRoi,
|
|
817
888
|
};
|
|
818
889
|
});
|
|
819
|
-
// Sort by total PnL descending
|
|
820
|
-
rows.sort((a, b) => b.totalPnl - a.totalPnl);
|
|
890
|
+
// Sort by total PnL descending (unknown totals last)
|
|
891
|
+
rows.sort((a, b) => (a.totalPnl === null ? 1 : b.totalPnl === null ? -1 : b.totalPnl - a.totalPnl));
|
|
892
|
+
// Totals skip the unknown (null) values; partial flags that they do not cover every row.
|
|
893
|
+
const sumKnown = (vals) => vals.reduce((s, v) => s + (v ?? 0), 0);
|
|
894
|
+
const complete = rows.filter(r => r.totalPnl !== null);
|
|
821
895
|
const totals = {
|
|
822
|
-
funding: rows.
|
|
823
|
-
pricePnl: rows.
|
|
824
|
-
totalPnl:
|
|
825
|
-
|
|
896
|
+
funding: sumKnown(rows.map(r => r.funding)),
|
|
897
|
+
pricePnl: sumKnown(rows.map(r => r.pricePnl)),
|
|
898
|
+
totalPnl: sumKnown(complete.map(r => r.totalPnl)),
|
|
899
|
+
// ROI base: only the rows whose total PnL is known.
|
|
900
|
+
notional: complete.reduce((s, r) => s + r.notional, 0),
|
|
826
901
|
};
|
|
902
|
+
const partial = Object.keys(errors).length > 0 || rows.some(r => r.funding === null || r.pricePnl === null);
|
|
827
903
|
const result = {
|
|
828
904
|
period: opts.period ?? "all",
|
|
829
905
|
positionCount: rows.length,
|
|
@@ -833,6 +909,8 @@ function registerAnalyticsSubcommands(parent, getAdapterForExchange, isJson) {
|
|
|
833
909
|
...totals,
|
|
834
910
|
roi: totals.notional > 0 ? (totals.totalPnl / totals.notional) * 100 : 0,
|
|
835
911
|
},
|
|
912
|
+
errors,
|
|
913
|
+
partial,
|
|
836
914
|
};
|
|
837
915
|
if (isJson())
|
|
838
916
|
return printJson(jsonOk(result));
|
|
@@ -843,11 +921,11 @@ function registerAnalyticsSubcommands(parent, getAdapterForExchange, isJson) {
|
|
|
843
921
|
`${r.longExchange} → ${r.shortExchange}`,
|
|
844
922
|
r.mode,
|
|
845
923
|
`${r.daysHeld}d`,
|
|
846
|
-
formatPnl(r.funding),
|
|
847
|
-
formatPnl(r.pricePnl),
|
|
848
|
-
formatPnl(r.totalPnl),
|
|
849
|
-
`${r.roi >= 0 ? "+" : ""}${r.roi.toFixed(2)}
|
|
850
|
-
`${r.annualizedRoi >= 0 ? "+" : ""}${r.annualizedRoi.toFixed(1)}
|
|
924
|
+
r.funding !== null ? formatPnl(r.funding) : chalk.yellow("unavailable"),
|
|
925
|
+
r.pricePnl !== null ? formatPnl(r.pricePnl) : chalk.yellow("unavailable"),
|
|
926
|
+
r.totalPnl !== null ? formatPnl(r.totalPnl) : chalk.yellow("unavailable"),
|
|
927
|
+
r.roi !== null ? `${r.roi >= 0 ? "+" : ""}${r.roi.toFixed(2)}%` : chalk.yellow("unavailable"),
|
|
928
|
+
r.annualizedRoi !== null ? `${r.annualizedRoi >= 0 ? "+" : ""}${r.annualizedRoi.toFixed(1)}%` : chalk.yellow("unavailable"),
|
|
851
929
|
]);
|
|
852
930
|
console.log(makeTable(["Symbol", "Long → Short", "Mode", "Held", "Funding", "Price PnL", "Total PnL", "ROI", "Ann.ROI"], tableRows));
|
|
853
931
|
if (unpricedPositions.length > 0)
|
|
@@ -859,6 +937,10 @@ function registerAnalyticsSubcommands(parent, getAdapterForExchange, isJson) {
|
|
|
859
937
|
if (totals.notional > 0) {
|
|
860
938
|
console.log(` Total ROI: ${result.totals.roi >= 0 ? "+" : ""}${result.totals.roi.toFixed(2)}%`);
|
|
861
939
|
}
|
|
940
|
+
if (partial)
|
|
941
|
+
console.log(chalk.yellow(" ⚠ Partial: totals leave out the unavailable values above"));
|
|
942
|
+
for (const [k, msg] of Object.entries(errors))
|
|
943
|
+
console.log(chalk.yellow(` ⚠ ${k} unavailable: ${msg}`));
|
|
862
944
|
console.log();
|
|
863
945
|
});
|
|
864
946
|
});
|
|
@@ -17,4 +17,9 @@
|
|
|
17
17
|
import type { Command } from "commander";
|
|
18
18
|
import type { ExchangeAdapter } from "../exchanges/index.js";
|
|
19
19
|
import "../exchanges/venues/index.js";
|
|
20
|
+
/**
|
|
21
|
+
* Open-position count on a HIP-3 dex; null = the read failed (unknown — NOT "no positions").
|
|
22
|
+
* The failure is warned on stderr; callers must treat null as "may hold positions".
|
|
23
|
+
*/
|
|
24
|
+
export declare function checkDexPositions(address: string, dex: string): Promise<number | null>;
|
|
20
25
|
export declare function registerPortfolioCommand(program: Command, getAdapterForExchange: (exchange: string) => Promise<ExchangeAdapter>, isJson: () => boolean, version: string, getHLAdapterForDex?: (dex: string) => Promise<ExchangeAdapter>): void;
|