perp-cli 0.4.8 → 0.4.9
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 +44 -5
- package/dist/arb/reconcile.d.ts +28 -0
- package/dist/arb/reconcile.js +162 -0
- package/dist/commands/account.d.ts +1 -1
- package/dist/commands/account.js +300 -63
- package/dist/commands/agent.js +9 -5
- package/dist/commands/arb/index.js +243 -49
- package/dist/commands/arb-auto.js +67 -19
- package/dist/commands/arb.d.ts +2 -1
- package/dist/commands/arb.js +535 -131
- package/dist/commands/bot.js +207 -0
- package/dist/commands/funds.d.ts +4 -0
- package/dist/commands/funds.js +741 -0
- package/dist/commands/history.d.ts +2 -1
- package/dist/commands/history.js +508 -5
- package/dist/commands/manage.js +5 -3
- package/dist/commands/market.js +34 -1
- package/dist/commands/pnl.js +1 -1
- package/dist/commands/risk.d.ts +2 -0
- package/dist/commands/risk.js +56 -0
- package/dist/commands/settings.js +147 -0
- package/dist/commands/trade.d.ts +1 -1
- package/dist/commands/trade.js +241 -259
- package/dist/commands/wallet.d.ts +4 -0
- package/dist/commands/wallet.js +10 -0
- package/dist/config.d.ts +2 -2
- package/dist/config.js +11 -3
- package/dist/exchanges/hyperliquid-spot.d.ts +1 -0
- package/dist/exchanges/hyperliquid-spot.js +13 -21
- package/dist/exchanges/lighter-spot.js +13 -15
- package/dist/exchanges/lighter.js +82 -21
- package/dist/exchanges/pacifica.js +12 -0
- package/dist/execution-log.d.ts +1 -1
- package/dist/index.js +39 -123
- package/dist/mcp-server.js +43 -96
- package/dist/strategies/split-order.d.ts +49 -0
- package/dist/strategies/split-order.js +138 -0
- package/package.json +1 -1
- package/skills/perp-cli/SKILL.md +13 -26
- package/skills/perp-cli/references/agent-operations.md +19 -9
- package/skills/perp-cli/references/commands.md +38 -31
- package/skills/perp-cli/references/strategies.md +26 -16
- package/skills/perp-cli/scripts/funding-analysis.sh +2 -2
- package/skills/perp-cli/scripts/preflight.sh +1 -1
- package/skills/perp-cli/scripts/spot-perp-scan.sh +1 -1
package/README.md
CHANGED
|
@@ -13,9 +13,10 @@ npx -y perp-cli --json portfolio
|
|
|
13
13
|
## Features
|
|
14
14
|
|
|
15
15
|
- **3 Exchanges** — trade, bridge, arbitrage across Pacifica, Hyperliquid, Lighter
|
|
16
|
-
- **Funding Rate Arb** — scan
|
|
16
|
+
- **Funding Rate Arb** — perp-perp + spot-perp scan & one-command dual-leg execution
|
|
17
17
|
- **Portfolio** — single call returns balances, positions, risk level across all exchanges
|
|
18
|
-
- **
|
|
18
|
+
- **Funds** — deposit, withdraw, CCTP bridge, internal transfer in one group
|
|
19
|
+
- **Bots** — TWAP, grid, DCA, trailing-stop with background job management
|
|
19
20
|
- **Agent-First Design** — `--json`, `--fields`, `--ndjson`, `--dry-run`, runtime schema introspection
|
|
20
21
|
- **Safety** — pre-trade validation, response sanitization, client-id deduplication
|
|
21
22
|
|
|
@@ -35,6 +36,30 @@ Same EVM key works for both Hyperliquid and Lighter.
|
|
|
35
36
|
|
|
36
37
|
> **Lighter API Key Index:** Indexes 0–3 are reserved by Lighter's frontend (web/mobile). perp-cli defaults to index `4`. Override with `LIGHTER_API_KEY_INDEX` env var or `--key-index` flag on `manage setup-api-key`. Valid range: 4–254.
|
|
37
38
|
|
|
39
|
+
## Command Groups
|
|
40
|
+
|
|
41
|
+
| Group | Description |
|
|
42
|
+
|-------|-------------|
|
|
43
|
+
| `market` | Prices, orderbook, funding, klines, HIP-3 dexes |
|
|
44
|
+
| `account` | Balance, positions, orders, margin |
|
|
45
|
+
| `trade` | Market/limit/stop orders, close, scale, split execution |
|
|
46
|
+
| `arb` | Funding rate arb — scan, exec, close, monitor (perp-perp & spot-perp) |
|
|
47
|
+
| `bot` | TWAP, grid, DCA, trailing-stop bots |
|
|
48
|
+
| `funds` | Deposit, withdraw, transfer, CCTP bridge |
|
|
49
|
+
| `bridge` | Cross-chain USDC bridge (deBridge DLN) |
|
|
50
|
+
| `risk` | Risk limits, liquidation distance, guardrails |
|
|
51
|
+
| `wallet` | Multi-wallet management & on-chain balances |
|
|
52
|
+
| `history` | Execution log, PnL, performance breakdown |
|
|
53
|
+
| `manage` | Margin mode, subaccount, API keys, builder |
|
|
54
|
+
| `portfolio` | Cross-exchange unified overview |
|
|
55
|
+
| `dashboard` | Live web dashboard |
|
|
56
|
+
| `settings` | CLI settings (referrals, defaults) |
|
|
57
|
+
| `backtest` | Strategy backtesting |
|
|
58
|
+
| `plan` | Multi-step composite execution plans |
|
|
59
|
+
| `rebalance` | Cross-exchange balance management |
|
|
60
|
+
| `jobs` | Background job management (tmux) |
|
|
61
|
+
| `agent` | Schema introspection, capabilities, health check |
|
|
62
|
+
|
|
38
63
|
## Core Commands
|
|
39
64
|
|
|
40
65
|
```bash
|
|
@@ -48,18 +73,32 @@ perp --json -e <EX> market funding <SYM>
|
|
|
48
73
|
|
|
49
74
|
# Trading
|
|
50
75
|
perp --json -e <EX> trade market <SYM> buy <SIZE>
|
|
76
|
+
perp --json -e <EX> trade market <SYM> buy <SIZE> --smart # IOC limit at best bid/ask (less slippage)
|
|
77
|
+
perp --json -e <EX> trade split <SYM> buy 5000 # orderbook-aware split (large orders)
|
|
51
78
|
perp --json -e <EX> trade close <SYM>
|
|
52
79
|
perp --json -e <EX> trade leverage <SYM> <N>
|
|
53
80
|
|
|
54
81
|
# Funding rate arbitrage
|
|
55
|
-
perp --json arb scan --min 5 #
|
|
82
|
+
perp --json arb scan --min 5 # perp-perp opportunities
|
|
83
|
+
perp --json arb scan --mode spot-perp # spot+perp opportunities
|
|
56
84
|
perp --json arb exec <SYM> <longEx> <shortEx> <$> --leverage 2 --isolated # execute both legs
|
|
85
|
+
perp --json arb spot-exec <SYM> <spotEx> <perpEx> <$> # spot+perp entry
|
|
86
|
+
|
|
87
|
+
# Funds (deposit, withdraw, transfer)
|
|
88
|
+
perp --json funds deposit hyperliquid 100
|
|
89
|
+
perp --json funds withdraw pacifica 50
|
|
90
|
+
perp --json funds transfer 100 <ADDRESS> # HL internal transfer
|
|
91
|
+
perp --json funds info # all routes & limits
|
|
57
92
|
|
|
58
93
|
# Risk
|
|
59
94
|
perp --json risk limits --max-leverage 5
|
|
60
95
|
perp --json risk liquidation-distance
|
|
61
96
|
|
|
62
|
-
#
|
|
97
|
+
# Bots
|
|
98
|
+
perp --json bot twap <SYM> buy <SIZE> 30m
|
|
99
|
+
perp --json bot grid <SYM> --range 5 --grids 10 --size 100
|
|
100
|
+
|
|
101
|
+
# Bridge (cross-chain USDC)
|
|
63
102
|
perp --json bridge quote --from solana --to arbitrum --amount 100
|
|
64
103
|
perp --json bridge send --from solana --to arbitrum --amount 100
|
|
65
104
|
```
|
|
@@ -92,7 +131,7 @@ perp --json portfolio
|
|
|
92
131
|
# Runtime schema introspection (don't guess commands — query this)
|
|
93
132
|
perp --json agent schema
|
|
94
133
|
|
|
95
|
-
# Filter output to
|
|
134
|
+
# Filter output to specific fields (saves tokens)
|
|
96
135
|
perp --json --fields totalEquity,risk portfolio
|
|
97
136
|
|
|
98
137
|
# Stream large lists as NDJSON (one JSON per line)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { ExchangeAdapter } from "../exchanges/interface.js";
|
|
2
|
+
export interface ReconciliationIssue {
|
|
3
|
+
type: "orphan" | "phantom" | "size_mismatch";
|
|
4
|
+
symbol: string;
|
|
5
|
+
exchange: string;
|
|
6
|
+
side: "long" | "short";
|
|
7
|
+
localSize: number | null;
|
|
8
|
+
exchangeSize: number | null;
|
|
9
|
+
severity: "warning" | "critical";
|
|
10
|
+
suggestion: string;
|
|
11
|
+
}
|
|
12
|
+
export interface ReconciliationResult {
|
|
13
|
+
issues: ReconciliationIssue[];
|
|
14
|
+
healthy: number;
|
|
15
|
+
fixesApplied?: string[];
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Reconcile local arb state with actual exchange positions.
|
|
19
|
+
*
|
|
20
|
+
* Detects three types of issues:
|
|
21
|
+
* - **Orphan**: Position exists on exchange but not in local arb state (manual entry?)
|
|
22
|
+
* - **Phantom**: Position exists in local state but not on exchange (liquidated? manually closed?)
|
|
23
|
+
* - **Size mismatch**: Both exist but sizes differ beyond tolerance
|
|
24
|
+
*/
|
|
25
|
+
export declare function reconcileArbPositions(adapters: Map<string, ExchangeAdapter>, opts?: {
|
|
26
|
+
fix?: boolean;
|
|
27
|
+
tolerancePct?: number;
|
|
28
|
+
}): Promise<ReconciliationResult>;
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { getPositions, removePosition, updatePosition } from "./state.js";
|
|
2
|
+
/** Normalize symbol for comparison (strip -PERP suffix, uppercase) */
|
|
3
|
+
function normalizeSymbol(symbol) {
|
|
4
|
+
return symbol.toUpperCase().replace(/-PERP$/, "");
|
|
5
|
+
}
|
|
6
|
+
/** Find a matching exchange position by symbol */
|
|
7
|
+
function findExchangePosition(positions, symbol, side) {
|
|
8
|
+
const norm = normalizeSymbol(symbol);
|
|
9
|
+
return positions.find(p => normalizeSymbol(p.symbol) === norm && p.side === side);
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Reconcile local arb state with actual exchange positions.
|
|
13
|
+
*
|
|
14
|
+
* Detects three types of issues:
|
|
15
|
+
* - **Orphan**: Position exists on exchange but not in local arb state (manual entry?)
|
|
16
|
+
* - **Phantom**: Position exists in local state but not on exchange (liquidated? manually closed?)
|
|
17
|
+
* - **Size mismatch**: Both exist but sizes differ beyond tolerance
|
|
18
|
+
*/
|
|
19
|
+
export async function reconcileArbPositions(adapters, opts) {
|
|
20
|
+
const tolerancePct = opts?.tolerancePct ?? 1;
|
|
21
|
+
const shouldFix = opts?.fix ?? false;
|
|
22
|
+
const issues = [];
|
|
23
|
+
const fixesApplied = [];
|
|
24
|
+
let healthy = 0;
|
|
25
|
+
const localPositions = getPositions();
|
|
26
|
+
// Fetch all exchange positions
|
|
27
|
+
const exchangePositions = new Map();
|
|
28
|
+
for (const [name, adapter] of adapters) {
|
|
29
|
+
try {
|
|
30
|
+
const pos = await adapter.getPositions();
|
|
31
|
+
exchangePositions.set(name, pos);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
exchangePositions.set(name, []);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
// Track which exchange positions are accounted for
|
|
38
|
+
const accountedFor = new Set(); // "exchange:symbol:side"
|
|
39
|
+
// Check each local arb position against exchange
|
|
40
|
+
for (const arb of localPositions) {
|
|
41
|
+
// Check long leg
|
|
42
|
+
const longPositions = exchangePositions.get(arb.longExchange) ?? [];
|
|
43
|
+
const longMatch = findExchangePosition(longPositions, arb.symbol, "long");
|
|
44
|
+
if (!longMatch) {
|
|
45
|
+
issues.push({
|
|
46
|
+
type: "phantom",
|
|
47
|
+
symbol: arb.symbol,
|
|
48
|
+
exchange: arb.longExchange,
|
|
49
|
+
side: "long",
|
|
50
|
+
localSize: arb.longSize,
|
|
51
|
+
exchangeSize: null,
|
|
52
|
+
severity: "critical",
|
|
53
|
+
suggestion: `Local state has long ${arb.symbol} on ${arb.longExchange} but exchange has no position. May have been liquidated or manually closed.`,
|
|
54
|
+
});
|
|
55
|
+
if (shouldFix) {
|
|
56
|
+
removePosition(arb.symbol);
|
|
57
|
+
fixesApplied.push(`Removed phantom position ${arb.symbol} (long leg missing on ${arb.longExchange})`);
|
|
58
|
+
continue; // Skip short leg check since we removed the position
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
accountedFor.add(`${arb.longExchange}:${normalizeSymbol(arb.symbol)}:long`);
|
|
63
|
+
const exchangeSize = Math.abs(Number(longMatch.size));
|
|
64
|
+
const localSize = Math.abs(arb.longSize);
|
|
65
|
+
const diffPct = localSize > 0 ? (Math.abs(exchangeSize - localSize) / localSize) * 100 : 0;
|
|
66
|
+
if (diffPct > tolerancePct) {
|
|
67
|
+
issues.push({
|
|
68
|
+
type: "size_mismatch",
|
|
69
|
+
symbol: arb.symbol,
|
|
70
|
+
exchange: arb.longExchange,
|
|
71
|
+
side: "long",
|
|
72
|
+
localSize,
|
|
73
|
+
exchangeSize,
|
|
74
|
+
severity: "warning",
|
|
75
|
+
suggestion: `Long size mismatch: local ${localSize} vs exchange ${exchangeSize} (${diffPct.toFixed(1)}% diff). Partial fill or manual adjustment?`,
|
|
76
|
+
});
|
|
77
|
+
if (shouldFix) {
|
|
78
|
+
updatePosition(arb.symbol, { longSize: exchangeSize });
|
|
79
|
+
fixesApplied.push(`Updated ${arb.symbol} long size: ${localSize} → ${exchangeSize}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
healthy++;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// Check short leg
|
|
87
|
+
const shortPositions = exchangePositions.get(arb.shortExchange) ?? [];
|
|
88
|
+
const shortMatch = findExchangePosition(shortPositions, arb.symbol, "short");
|
|
89
|
+
if (!shortMatch) {
|
|
90
|
+
// Don't double-report if we already removed via fix
|
|
91
|
+
if (shouldFix && !getPositions().find(p => p.symbol === arb.symbol))
|
|
92
|
+
continue;
|
|
93
|
+
issues.push({
|
|
94
|
+
type: "phantom",
|
|
95
|
+
symbol: arb.symbol,
|
|
96
|
+
exchange: arb.shortExchange,
|
|
97
|
+
side: "short",
|
|
98
|
+
localSize: arb.shortSize,
|
|
99
|
+
exchangeSize: null,
|
|
100
|
+
severity: "critical",
|
|
101
|
+
suggestion: `Local state has short ${arb.symbol} on ${arb.shortExchange} but exchange has no position. May have been liquidated or manually closed.`,
|
|
102
|
+
});
|
|
103
|
+
if (shouldFix) {
|
|
104
|
+
removePosition(arb.symbol);
|
|
105
|
+
fixesApplied.push(`Removed phantom position ${arb.symbol} (short leg missing on ${arb.shortExchange})`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
accountedFor.add(`${arb.shortExchange}:${normalizeSymbol(arb.symbol)}:short`);
|
|
110
|
+
const exchangeSize = Math.abs(Number(shortMatch.size));
|
|
111
|
+
const localSize = Math.abs(arb.shortSize);
|
|
112
|
+
const diffPct = localSize > 0 ? (Math.abs(exchangeSize - localSize) / localSize) * 100 : 0;
|
|
113
|
+
if (diffPct > tolerancePct) {
|
|
114
|
+
issues.push({
|
|
115
|
+
type: "size_mismatch",
|
|
116
|
+
symbol: arb.symbol,
|
|
117
|
+
exchange: arb.shortExchange,
|
|
118
|
+
side: "short",
|
|
119
|
+
localSize,
|
|
120
|
+
exchangeSize,
|
|
121
|
+
severity: "warning",
|
|
122
|
+
suggestion: `Short size mismatch: local ${localSize} vs exchange ${exchangeSize} (${diffPct.toFixed(1)}% diff). Partial fill or manual adjustment?`,
|
|
123
|
+
});
|
|
124
|
+
if (shouldFix) {
|
|
125
|
+
updatePosition(arb.symbol, { shortSize: exchangeSize });
|
|
126
|
+
fixesApplied.push(`Updated ${arb.symbol} short size: ${localSize} → ${exchangeSize}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
healthy++;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
// Check for orphan positions (on exchange but not in local state)
|
|
135
|
+
for (const [exchangeName, positions] of exchangePositions) {
|
|
136
|
+
for (const pos of positions) {
|
|
137
|
+
const key = `${exchangeName}:${normalizeSymbol(pos.symbol)}:${pos.side}`;
|
|
138
|
+
if (!accountedFor.has(key)) {
|
|
139
|
+
// Check if this exchange is used in any arb position
|
|
140
|
+
const isArbExchange = localPositions.some(p => p.longExchange === exchangeName || p.shortExchange === exchangeName);
|
|
141
|
+
// Only report orphans on exchanges that are part of arb state
|
|
142
|
+
if (isArbExchange || localPositions.length > 0) {
|
|
143
|
+
issues.push({
|
|
144
|
+
type: "orphan",
|
|
145
|
+
symbol: pos.symbol,
|
|
146
|
+
exchange: exchangeName,
|
|
147
|
+
side: pos.side,
|
|
148
|
+
localSize: null,
|
|
149
|
+
exchangeSize: Math.abs(Number(pos.size)),
|
|
150
|
+
severity: "warning",
|
|
151
|
+
suggestion: `Position on ${exchangeName} not tracked in arb state. Manual entry or untracked strategy?`,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return {
|
|
158
|
+
issues,
|
|
159
|
+
healthy,
|
|
160
|
+
...(fixesApplied.length > 0 ? { fixesApplied } : {}),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
2
|
import type { ExchangeAdapter } from "../exchanges/interface.js";
|
|
3
|
-
export declare function registerAccountCommands(program: Command, getAdapter: () => Promise<ExchangeAdapter>, isJson: () => boolean): void;
|
|
3
|
+
export declare function registerAccountCommands(program: Command, getAdapter: () => Promise<ExchangeAdapter>, isJson: () => boolean, getAdapterForExchange?: (exchange: string) => Promise<ExchangeAdapter>): void;
|
package/dist/commands/account.js
CHANGED
|
@@ -1,42 +1,210 @@
|
|
|
1
1
|
import { PacificaAdapter } from "../exchanges/pacifica.js";
|
|
2
|
-
import {
|
|
3
|
-
import { makeTable, formatUsd, formatPnl, printJson, jsonOk, jsonError, symbolMatch } from "../utils.js";
|
|
2
|
+
import { makeTable, formatUsd, formatPnl, printJson, jsonOk, jsonError, symbolMatch, withJsonErrors } from "../utils.js";
|
|
4
3
|
import chalk from "chalk";
|
|
4
|
+
import { assessRisk } from "../risk.js";
|
|
5
|
+
const EXCHANGES = ["pacifica", "hyperliquid", "lighter"];
|
|
6
|
+
async function fetchExchangeSnapshot(name, getAdapter) {
|
|
7
|
+
try {
|
|
8
|
+
const adapter = await getAdapter(name);
|
|
9
|
+
const [balance, positions, orders] = await Promise.all([
|
|
10
|
+
adapter.getBalance(),
|
|
11
|
+
adapter.getPositions(),
|
|
12
|
+
adapter.getOpenOrders(),
|
|
13
|
+
]);
|
|
14
|
+
return {
|
|
15
|
+
exchange: name,
|
|
16
|
+
connected: true,
|
|
17
|
+
balance,
|
|
18
|
+
positions,
|
|
19
|
+
openOrders: orders.length,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
catch (err) {
|
|
23
|
+
return {
|
|
24
|
+
exchange: name,
|
|
25
|
+
connected: false,
|
|
26
|
+
balance: null,
|
|
27
|
+
positions: [],
|
|
28
|
+
openOrders: 0,
|
|
29
|
+
error: err instanceof Error ? err.message : String(err),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function buildSummary(snapshots) {
|
|
34
|
+
let totalEquity = 0;
|
|
35
|
+
let totalAvailable = 0;
|
|
36
|
+
let totalMarginUsed = 0;
|
|
37
|
+
let totalUnrealizedPnl = 0;
|
|
38
|
+
let totalPositions = 0;
|
|
39
|
+
let totalOpenOrders = 0;
|
|
40
|
+
const allPositions = [];
|
|
41
|
+
for (const snap of snapshots) {
|
|
42
|
+
if (snap.balance) {
|
|
43
|
+
totalEquity += Number(snap.balance.equity);
|
|
44
|
+
totalAvailable += Number(snap.balance.available);
|
|
45
|
+
totalMarginUsed += Number(snap.balance.marginUsed);
|
|
46
|
+
totalUnrealizedPnl += Number(snap.balance.unrealizedPnl);
|
|
47
|
+
}
|
|
48
|
+
totalPositions += snap.positions.length;
|
|
49
|
+
totalOpenOrders += snap.openOrders;
|
|
50
|
+
for (const pos of snap.positions) {
|
|
51
|
+
allPositions.push({ ...pos, exchange: snap.exchange });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
// Risk metrics
|
|
55
|
+
const marginUtilization = totalEquity > 0 ? (totalMarginUsed / totalEquity) * 100 : 0;
|
|
56
|
+
let largestPosition = null;
|
|
57
|
+
for (const pos of allPositions) {
|
|
58
|
+
const notional = Math.abs(Number(pos.size) * Number(pos.markPrice));
|
|
59
|
+
if (!largestPosition || notional > largestPosition.notional) {
|
|
60
|
+
largestPosition = { symbol: pos.symbol, exchange: pos.exchange, notional };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const exchangeConcentration = snapshots
|
|
64
|
+
.filter(s => s.balance && Number(s.balance.equity) > 0)
|
|
65
|
+
.map(s => ({
|
|
66
|
+
exchange: s.exchange,
|
|
67
|
+
pct: totalEquity > 0 ? (Number(s.balance.equity) / totalEquity) * 100 : 0,
|
|
68
|
+
}))
|
|
69
|
+
.sort((a, b) => b.pct - a.pct);
|
|
70
|
+
// Full risk assessment (reuses same data, no extra API calls)
|
|
71
|
+
const riskBalances = snapshots
|
|
72
|
+
.filter(s => s.balance)
|
|
73
|
+
.map(s => ({ exchange: s.exchange, balance: s.balance }));
|
|
74
|
+
const riskPositions = allPositions.map(p => ({ exchange: p.exchange, position: p }));
|
|
75
|
+
const assessment = assessRisk(riskBalances, riskPositions);
|
|
76
|
+
return {
|
|
77
|
+
totalEquity,
|
|
78
|
+
totalAvailable,
|
|
79
|
+
totalMarginUsed,
|
|
80
|
+
totalUnrealizedPnl,
|
|
81
|
+
totalPositions,
|
|
82
|
+
totalOpenOrders,
|
|
83
|
+
exchanges: snapshots,
|
|
84
|
+
positions: allPositions,
|
|
85
|
+
riskMetrics: {
|
|
86
|
+
marginUtilization,
|
|
87
|
+
largestPosition,
|
|
88
|
+
exchangeConcentration,
|
|
89
|
+
},
|
|
90
|
+
risk: {
|
|
91
|
+
level: assessment.level,
|
|
92
|
+
canTrade: assessment.canTrade,
|
|
93
|
+
violations: assessment.violations,
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
5
97
|
function pac(adapter) {
|
|
6
98
|
if (!(adapter instanceof PacificaAdapter))
|
|
7
99
|
throw new Error("This command requires --exchange pacifica");
|
|
8
100
|
return adapter;
|
|
9
101
|
}
|
|
10
|
-
export function registerAccountCommands(program, getAdapter, isJson) {
|
|
102
|
+
export function registerAccountCommands(program, getAdapter, isJson, getAdapterForExchange) {
|
|
11
103
|
const account = program.command("account").description("Account commands");
|
|
12
|
-
account
|
|
13
|
-
.command("info")
|
|
14
|
-
.description("Show account balance and info")
|
|
15
|
-
.action(async () => {
|
|
16
|
-
const adapter = await getAdapter();
|
|
17
|
-
const bal = await adapter.getBalance();
|
|
18
|
-
if (isJson())
|
|
19
|
-
return printJson(jsonOk(bal));
|
|
20
|
-
console.log(chalk.cyan.bold(`\n ${adapter.name.toUpperCase()} Account Info\n`));
|
|
21
|
-
console.log(` Equity: $${formatUsd(bal.equity)}`);
|
|
22
|
-
console.log(` Available: $${formatUsd(bal.available)}`);
|
|
23
|
-
console.log(` Margin Used: $${formatUsd(bal.marginUsed)}`);
|
|
24
|
-
console.log(` Unreal. PnL: ${formatPnl(bal.unrealizedPnl)}`);
|
|
25
|
-
console.log();
|
|
26
|
-
});
|
|
27
104
|
account
|
|
28
105
|
.command("balance")
|
|
29
|
-
.
|
|
106
|
+
.alias("info")
|
|
107
|
+
.description("Account overview: perp balance, spot holdings, and 24h funding")
|
|
30
108
|
.action(async () => {
|
|
31
109
|
const adapter = await getAdapter();
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
110
|
+
const exName = adapter.name.toLowerCase();
|
|
111
|
+
// Fetch perp balance + funding in parallel
|
|
112
|
+
const [bal, fundingPayments] = await Promise.all([
|
|
113
|
+
adapter.getBalance(),
|
|
114
|
+
adapter.getFundingPayments(200).catch(() => []),
|
|
115
|
+
]);
|
|
116
|
+
// Spot balances (HL and LT only)
|
|
117
|
+
let spotBalances = [];
|
|
118
|
+
try {
|
|
119
|
+
if (exName === "hyperliquid") {
|
|
120
|
+
const { HyperliquidSpotAdapter } = await import("../exchanges/hyperliquid-spot.js");
|
|
121
|
+
const hlSpot = new HyperliquidSpotAdapter(adapter);
|
|
122
|
+
await hlSpot.init();
|
|
123
|
+
const raw = await hlSpot.getSpotBalances();
|
|
124
|
+
// Get spot prices for USD valuation
|
|
125
|
+
const markets = await hlSpot.getSpotMarkets();
|
|
126
|
+
const priceMap = new Map(markets.map(m => [m.baseToken.toUpperCase(), Number(m.markPrice)]));
|
|
127
|
+
// HL spot API returns token names with -SPOT suffix (e.g., "HYPE-SPOT", "USDC-SPOT")
|
|
128
|
+
const stripSuffix = (t) => t.replace(/-SPOT$/i, "").toUpperCase();
|
|
129
|
+
spotBalances = raw
|
|
130
|
+
.filter(b => Number(b.total) > 0)
|
|
131
|
+
.map(b => {
|
|
132
|
+
const base = stripSuffix(b.token);
|
|
133
|
+
return {
|
|
134
|
+
...b,
|
|
135
|
+
valueUsd: base === "USDC" ? Number(b.total) : (priceMap.get(base) ?? 0) * Number(b.total),
|
|
136
|
+
};
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
else if (exName === "lighter") {
|
|
140
|
+
const { LighterAdapter } = await import("../exchanges/lighter.js");
|
|
141
|
+
const { LighterSpotAdapter } = await import("../exchanges/lighter-spot.js");
|
|
142
|
+
const ltSpot = new LighterSpotAdapter(adapter);
|
|
143
|
+
await ltSpot.init();
|
|
144
|
+
const raw = await ltSpot.getSpotBalances();
|
|
145
|
+
const markets = await ltSpot.getSpotMarkets();
|
|
146
|
+
const priceMap = new Map(markets.map(m => [m.baseToken.toUpperCase(), Number(m.markPrice)]));
|
|
147
|
+
spotBalances = raw
|
|
148
|
+
.filter(b => Number(b.total) > 0)
|
|
149
|
+
.map(b => ({
|
|
150
|
+
...b,
|
|
151
|
+
valueUsd: b.token === "USDC" || b.token === "USDC_SPOT"
|
|
152
|
+
? Number(b.total)
|
|
153
|
+
: (priceMap.get(b.token.toUpperCase()) ?? 0) * Number(b.total),
|
|
154
|
+
}));
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
catch { /* spot not available */ }
|
|
158
|
+
// 24h funding summary
|
|
159
|
+
const cutoff24h = Date.now() - 24 * 60 * 60 * 1000;
|
|
160
|
+
const recent = fundingPayments.filter(f => f.time >= cutoff24h);
|
|
161
|
+
const funding24h = recent.reduce((sum, f) => sum + Number(f.payment), 0);
|
|
162
|
+
// Total spot value
|
|
163
|
+
const totalSpotUsd = spotBalances.reduce((s, b) => s + (b.valueUsd ?? 0), 0);
|
|
164
|
+
if (isJson()) {
|
|
165
|
+
return printJson(jsonOk({
|
|
166
|
+
perp: bal,
|
|
167
|
+
spot: spotBalances,
|
|
168
|
+
funding24h,
|
|
169
|
+
fundingPayments24h: recent.length,
|
|
170
|
+
totalSpotValueUsd: totalSpotUsd,
|
|
171
|
+
totalAccountValueUsd: Number(bal.equity) + totalSpotUsd,
|
|
172
|
+
}));
|
|
173
|
+
}
|
|
174
|
+
// ── Perp Balance ──
|
|
175
|
+
console.log(chalk.cyan.bold(`\n ${exName.toUpperCase()} Account Balance\n`));
|
|
176
|
+
console.log(chalk.white.bold(" Perp Account"));
|
|
177
|
+
console.log(` Equity: $${formatUsd(bal.equity)}`);
|
|
178
|
+
console.log(` Available: $${formatUsd(bal.available)}`);
|
|
179
|
+
console.log(` Margin Used: $${formatUsd(bal.marginUsed)}`);
|
|
180
|
+
console.log(` Unreal. PnL: ${formatPnl(bal.unrealizedPnl)}`);
|
|
181
|
+
// ── Spot Holdings ──
|
|
182
|
+
if (spotBalances.length > 0) {
|
|
183
|
+
console.log(chalk.white.bold("\n Spot Holdings"));
|
|
184
|
+
const rows = spotBalances.map(b => [
|
|
185
|
+
chalk.white.bold(b.token),
|
|
186
|
+
b.total,
|
|
187
|
+
b.available,
|
|
188
|
+
b.held !== "0" ? b.held : chalk.gray("-"),
|
|
189
|
+
b.valueUsd ? `$${formatUsd(b.valueUsd)}` : chalk.gray("-"),
|
|
190
|
+
]);
|
|
191
|
+
console.log(makeTable(["Token", "Total", "Available", "Held", "Value"], rows));
|
|
192
|
+
}
|
|
193
|
+
else if (exName === "hyperliquid" || exName === "lighter") {
|
|
194
|
+
console.log(chalk.gray("\n No spot holdings.\n"));
|
|
195
|
+
}
|
|
196
|
+
// ── Funding 24h ──
|
|
197
|
+
console.log(chalk.white.bold(" Funding (24h)"));
|
|
198
|
+
console.log(` Payments: ${recent.length}`);
|
|
199
|
+
console.log(` Total: ${formatPnl(funding24h)}`);
|
|
200
|
+
// ── Total ──
|
|
201
|
+
const totalValue = Number(bal.equity) + totalSpotUsd;
|
|
202
|
+
if (totalSpotUsd > 0) {
|
|
203
|
+
console.log(chalk.white.bold("\n Total"));
|
|
204
|
+
console.log(` Perp Equity: $${formatUsd(bal.equity)}`);
|
|
205
|
+
console.log(` Spot Value: $${formatUsd(totalSpotUsd)}`);
|
|
206
|
+
console.log(` Total Value: $${formatUsd(totalValue)}`);
|
|
207
|
+
}
|
|
40
208
|
console.log();
|
|
41
209
|
});
|
|
42
210
|
account
|
|
@@ -172,42 +340,9 @@ export function registerAccountCommands(program, getAdapter, isJson) {
|
|
|
172
340
|
]);
|
|
173
341
|
console.log(makeTable(["Time", "Symbol", "Payment"], rows));
|
|
174
342
|
});
|
|
175
|
-
account
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
.action(async () => {
|
|
179
|
-
const adapter = await getAdapter();
|
|
180
|
-
if (adapter instanceof PacificaAdapter) {
|
|
181
|
-
const portfolio = await adapter.sdk.getPortfolio(adapter.publicKey);
|
|
182
|
-
printJson(jsonOk(portfolio));
|
|
183
|
-
}
|
|
184
|
-
else if (adapter instanceof HyperliquidAdapter) {
|
|
185
|
-
const state = await adapter.client.info.perpetuals.getClearinghouseState(adapter.address);
|
|
186
|
-
printJson(jsonOk(state));
|
|
187
|
-
}
|
|
188
|
-
});
|
|
189
|
-
account
|
|
190
|
-
.command("balance-history")
|
|
191
|
-
.description("Balance change history")
|
|
192
|
-
.action(async () => {
|
|
193
|
-
const adapter = await getAdapter();
|
|
194
|
-
const p = pac(adapter);
|
|
195
|
-
const raw = await p.sdk.getBalanceHistory(p.publicKey);
|
|
196
|
-
if (isJson())
|
|
197
|
-
return printJson(jsonOk(raw));
|
|
198
|
-
const history = (raw.data ?? raw);
|
|
199
|
-
if (!Array.isArray(history) || history.length === 0) {
|
|
200
|
-
console.log(chalk.gray("\n No balance history.\n"));
|
|
201
|
-
return;
|
|
202
|
-
}
|
|
203
|
-
const rows = history.slice(0, 30).map((h) => [
|
|
204
|
-
new Date(Number(h.created_at ?? h.timestamp ?? 0)).toLocaleString(),
|
|
205
|
-
String(h.type ?? h.event_type ?? ""),
|
|
206
|
-
formatPnl(String(h.amount ?? h.change ?? "0")),
|
|
207
|
-
`$${formatUsd(String(h.balance ?? "0"))}`,
|
|
208
|
-
]);
|
|
209
|
-
console.log(makeTable(["Time", "Type", "Change", "Balance"], rows));
|
|
210
|
-
});
|
|
343
|
+
// "account portfolio" removed — use top-level "portfolio" for cross-exchange view
|
|
344
|
+
// or "account balance" for single-exchange balance
|
|
345
|
+
// "account balance-history" removed — Pacifica-only, rarely used
|
|
211
346
|
account
|
|
212
347
|
.command("margin <symbol>")
|
|
213
348
|
.description("Margin details for a specific symbol position")
|
|
@@ -422,4 +557,106 @@ export function registerAccountCommands(program, getAdapter, isJson) {
|
|
|
422
557
|
]);
|
|
423
558
|
console.log(makeTable(["ID", "Symbol", "Side", "Size", "Filled", "Duration"], rows));
|
|
424
559
|
});
|
|
560
|
+
// ── Portfolio (top-level command) ──
|
|
561
|
+
if (getAdapterForExchange) {
|
|
562
|
+
program
|
|
563
|
+
.command("portfolio")
|
|
564
|
+
.description("Cross-exchange portfolio overview (all exchanges at once)")
|
|
565
|
+
.option("--exchange <exchanges>", "Comma-separated exchanges to include (default: all)")
|
|
566
|
+
.action(async (opts) => {
|
|
567
|
+
await withJsonErrors(isJson(), async () => {
|
|
568
|
+
const exchanges = opts.exchange
|
|
569
|
+
? opts.exchange.split(",").map(e => e.trim())
|
|
570
|
+
: [...EXCHANGES];
|
|
571
|
+
const snapshots = await Promise.all(exchanges.map(ex => fetchExchangeSnapshot(ex, getAdapterForExchange)));
|
|
572
|
+
const summary = buildSummary(snapshots);
|
|
573
|
+
if (isJson()) {
|
|
574
|
+
return printJson(jsonOk(summary));
|
|
575
|
+
}
|
|
576
|
+
// ── Header ──
|
|
577
|
+
console.log(chalk.cyan.bold("\n Cross-Exchange Portfolio\n"));
|
|
578
|
+
// ── Balance Summary ──
|
|
579
|
+
console.log(chalk.white.bold(" Balances"));
|
|
580
|
+
const balRows = snapshots.map(s => {
|
|
581
|
+
if (!s.connected) {
|
|
582
|
+
return [chalk.white(s.exchange), chalk.red("disconnected"), "-", "-", "-", chalk.gray(s.error ?? "")];
|
|
583
|
+
}
|
|
584
|
+
const b = s.balance;
|
|
585
|
+
return [
|
|
586
|
+
chalk.white.bold(s.exchange),
|
|
587
|
+
`$${formatUsd(b.equity)}`,
|
|
588
|
+
`$${formatUsd(b.available)}`,
|
|
589
|
+
`$${formatUsd(b.marginUsed)}`,
|
|
590
|
+
formatPnl(b.unrealizedPnl),
|
|
591
|
+
chalk.green("connected"),
|
|
592
|
+
];
|
|
593
|
+
});
|
|
594
|
+
// Totals row
|
|
595
|
+
balRows.push([
|
|
596
|
+
chalk.cyan.bold("TOTAL"),
|
|
597
|
+
chalk.cyan.bold(`$${formatUsd(summary.totalEquity)}`),
|
|
598
|
+
chalk.cyan.bold(`$${formatUsd(summary.totalAvailable)}`),
|
|
599
|
+
chalk.cyan.bold(`$${formatUsd(summary.totalMarginUsed)}`),
|
|
600
|
+
formatPnl(summary.totalUnrealizedPnl),
|
|
601
|
+
"",
|
|
602
|
+
]);
|
|
603
|
+
console.log(makeTable(["Exchange", "Equity", "Available", "Margin Used", "uPnL", "Status"], balRows));
|
|
604
|
+
// ── Positions ──
|
|
605
|
+
if (summary.positions.length > 0) {
|
|
606
|
+
console.log(chalk.white.bold("\n Open Positions"));
|
|
607
|
+
const posRows = summary.positions.map(p => {
|
|
608
|
+
const sideColor = p.side === "long" ? chalk.green : chalk.red;
|
|
609
|
+
const notional = Math.abs(Number(p.size) * Number(p.markPrice));
|
|
610
|
+
return [
|
|
611
|
+
chalk.white.bold(p.symbol),
|
|
612
|
+
chalk.gray(p.exchange),
|
|
613
|
+
sideColor(p.side.toUpperCase()),
|
|
614
|
+
p.size,
|
|
615
|
+
`$${formatUsd(p.entryPrice)}`,
|
|
616
|
+
`$${formatUsd(p.markPrice)}`,
|
|
617
|
+
formatPnl(p.unrealizedPnl),
|
|
618
|
+
`$${formatUsd(notional)}`,
|
|
619
|
+
`${p.leverage}x`,
|
|
620
|
+
];
|
|
621
|
+
});
|
|
622
|
+
console.log(makeTable(["Symbol", "Exchange", "Side", "Size", "Entry", "Mark", "uPnL", "Notional", "Lev"], posRows));
|
|
623
|
+
}
|
|
624
|
+
else {
|
|
625
|
+
console.log(chalk.gray("\n No open positions.\n"));
|
|
626
|
+
}
|
|
627
|
+
// ── Risk Metrics ──
|
|
628
|
+
console.log(chalk.white.bold("\n Risk Metrics"));
|
|
629
|
+
const levelColor = {
|
|
630
|
+
low: chalk.green,
|
|
631
|
+
medium: chalk.yellow,
|
|
632
|
+
high: chalk.red,
|
|
633
|
+
critical: chalk.bgRed.white,
|
|
634
|
+
}[summary.risk.level];
|
|
635
|
+
console.log(` Risk Level: ${levelColor(summary.risk.level.toUpperCase())}`);
|
|
636
|
+
console.log(` Can Trade: ${summary.risk.canTrade ? chalk.green("YES") : chalk.red("NO")}`);
|
|
637
|
+
const mu = summary.riskMetrics.marginUtilization;
|
|
638
|
+
const muColor = mu < 30 ? chalk.green : mu < 60 ? chalk.yellow : chalk.red;
|
|
639
|
+
console.log(` Margin Utilization: ${muColor(`${mu.toFixed(1)}%`)}`);
|
|
640
|
+
if (summary.riskMetrics.largestPosition) {
|
|
641
|
+
const lp = summary.riskMetrics.largestPosition;
|
|
642
|
+
console.log(` Largest Position: ${lp.symbol} on ${lp.exchange} ($${formatUsd(lp.notional)})`);
|
|
643
|
+
}
|
|
644
|
+
if (summary.riskMetrics.exchangeConcentration.length > 0) {
|
|
645
|
+
console.log(` Exchange Allocation:`);
|
|
646
|
+
for (const ec of summary.riskMetrics.exchangeConcentration) {
|
|
647
|
+
const bar = "\u2588".repeat(Math.round(ec.pct / 5)) + "\u2591".repeat(20 - Math.round(ec.pct / 5));
|
|
648
|
+
console.log(` ${ec.exchange.padEnd(12)} ${bar} ${ec.pct.toFixed(1)}%`);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
if (summary.risk.violations.length > 0) {
|
|
652
|
+
console.log(chalk.red.bold("\n Risk Violations"));
|
|
653
|
+
for (const v of summary.risk.violations) {
|
|
654
|
+
const sevColor = { low: chalk.green, medium: chalk.yellow, high: chalk.red, critical: chalk.bgRed.white }[v.severity];
|
|
655
|
+
console.log(` ${sevColor(v.severity.toUpperCase().padEnd(8))} ${v.message}`);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
console.log();
|
|
659
|
+
});
|
|
660
|
+
});
|
|
661
|
+
}
|
|
425
662
|
}
|