@invizi/cli 0.1.5 → 0.1.7
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/commands/index.js +1 -0
- package/dist/commands/trades.js +289 -0
- package/dist/invizi.js +19 -11
- package/package.json +1 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { trades } from './trades.js';
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import { parseArgs } from 'node:util';
|
|
2
|
+
import { loadConfig } from '../config.js';
|
|
3
|
+
const HLP_API = 'https://api.hyperliquid.xyz/info';
|
|
4
|
+
const MAX_FILLS_PER_REQUEST = 2000;
|
|
5
|
+
const REQUEST_DELAY_MS = 200;
|
|
6
|
+
async function fetchFills(address, startTime, endTime) {
|
|
7
|
+
const body = {
|
|
8
|
+
type: 'userFillsByTime',
|
|
9
|
+
user: address,
|
|
10
|
+
startTime,
|
|
11
|
+
aggregateByTime: true,
|
|
12
|
+
};
|
|
13
|
+
if (endTime)
|
|
14
|
+
body.endTime = endTime;
|
|
15
|
+
const res = await fetch(HLP_API, {
|
|
16
|
+
method: 'POST',
|
|
17
|
+
headers: { 'Content-Type': 'application/json' },
|
|
18
|
+
body: JSON.stringify(body),
|
|
19
|
+
});
|
|
20
|
+
if (!res.ok) {
|
|
21
|
+
throw new Error(`Hyperliquid API error: ${res.status}`);
|
|
22
|
+
}
|
|
23
|
+
return (await res.json());
|
|
24
|
+
}
|
|
25
|
+
async function fetchAllFills(address, startTime) {
|
|
26
|
+
const allFills = [];
|
|
27
|
+
let cursor = startTime;
|
|
28
|
+
while (true) {
|
|
29
|
+
const fills = await fetchFills(address, cursor);
|
|
30
|
+
if (fills.length === 0)
|
|
31
|
+
break;
|
|
32
|
+
allFills.push(...fills);
|
|
33
|
+
if (fills.length < MAX_FILLS_PER_REQUEST)
|
|
34
|
+
break;
|
|
35
|
+
// Next page: start after last fill's time
|
|
36
|
+
cursor = fills[fills.length - 1].time + 1;
|
|
37
|
+
// Rate limit courtesy
|
|
38
|
+
await new Promise(r => setTimeout(r, REQUEST_DELAY_MS));
|
|
39
|
+
}
|
|
40
|
+
return allFills;
|
|
41
|
+
}
|
|
42
|
+
function computeStats(fills) {
|
|
43
|
+
const byCoin = new Map();
|
|
44
|
+
for (const fill of fills) {
|
|
45
|
+
const existing = byCoin.get(fill.coin) || [];
|
|
46
|
+
existing.push(fill);
|
|
47
|
+
byCoin.set(fill.coin, existing);
|
|
48
|
+
}
|
|
49
|
+
const stats = [];
|
|
50
|
+
for (const [coin, coinFills] of byCoin) {
|
|
51
|
+
let grossPnl = 0;
|
|
52
|
+
let fees = 0;
|
|
53
|
+
let volume = 0;
|
|
54
|
+
let trades = 0;
|
|
55
|
+
let wins = 0;
|
|
56
|
+
let losses = 0;
|
|
57
|
+
// Track trade cycles: accumulate closedPnl until position flips through zero
|
|
58
|
+
let cyclePnl = 0;
|
|
59
|
+
let inTrade = false;
|
|
60
|
+
for (const fill of coinFills) {
|
|
61
|
+
const pnl = parseFloat(fill.closedPnl);
|
|
62
|
+
const fee = parseFloat(fill.fee);
|
|
63
|
+
const size = parseFloat(fill.sz);
|
|
64
|
+
const price = parseFloat(fill.px);
|
|
65
|
+
const startPos = parseFloat(fill.startPosition);
|
|
66
|
+
grossPnl += pnl;
|
|
67
|
+
fees += fee;
|
|
68
|
+
volume += size * price;
|
|
69
|
+
if (pnl !== 0) {
|
|
70
|
+
// This is a closing fill
|
|
71
|
+
cyclePnl += pnl - fee;
|
|
72
|
+
// Check if position went to zero (trade cycle complete)
|
|
73
|
+
const endPos = fill.side === 'B'
|
|
74
|
+
? startPos + size
|
|
75
|
+
: startPos - size;
|
|
76
|
+
if (Math.abs(endPos) < 0.0000001 || Math.sign(endPos) !== Math.sign(startPos)) {
|
|
77
|
+
// Position closed or flipped
|
|
78
|
+
trades++;
|
|
79
|
+
if (cyclePnl > 0)
|
|
80
|
+
wins++;
|
|
81
|
+
else
|
|
82
|
+
losses++;
|
|
83
|
+
cyclePnl = 0;
|
|
84
|
+
inTrade = false;
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
inTrade = true;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
else if (!inTrade) {
|
|
91
|
+
inTrade = true;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// If there's an unclosed cycle with realized P&L, count it
|
|
95
|
+
if (inTrade && cyclePnl !== 0) {
|
|
96
|
+
trades++;
|
|
97
|
+
if (cyclePnl > 0)
|
|
98
|
+
wins++;
|
|
99
|
+
else
|
|
100
|
+
losses++;
|
|
101
|
+
}
|
|
102
|
+
// Fallback: if no trade cycles detected but there's P&L, count closing fills
|
|
103
|
+
if (trades === 0 && grossPnl !== 0) {
|
|
104
|
+
const closingFills = coinFills.filter(f => parseFloat(f.closedPnl) !== 0);
|
|
105
|
+
trades = closingFills.length;
|
|
106
|
+
wins = closingFills.filter(f => parseFloat(f.closedPnl) > 0).length;
|
|
107
|
+
losses = closingFills.filter(f => parseFloat(f.closedPnl) <= 0).length;
|
|
108
|
+
}
|
|
109
|
+
if (trades > 0 || grossPnl !== 0) {
|
|
110
|
+
stats.push({
|
|
111
|
+
coin,
|
|
112
|
+
trades,
|
|
113
|
+
wins,
|
|
114
|
+
losses,
|
|
115
|
+
grossPnl,
|
|
116
|
+
fees,
|
|
117
|
+
netPnl: grossPnl - fees,
|
|
118
|
+
volume,
|
|
119
|
+
lastTrade: coinFills[coinFills.length - 1].time,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
// Sort by absolute net P&L descending
|
|
124
|
+
stats.sort((a, b) => Math.abs(b.netPnl) - Math.abs(a.netPnl));
|
|
125
|
+
return stats;
|
|
126
|
+
}
|
|
127
|
+
function formatUsd(n) {
|
|
128
|
+
if (n >= 0)
|
|
129
|
+
return `+$${n.toFixed(2)}`;
|
|
130
|
+
return `-$${Math.abs(n).toFixed(2)}`;
|
|
131
|
+
}
|
|
132
|
+
function formatTable(stats, address, days) {
|
|
133
|
+
const lines = [];
|
|
134
|
+
const addrShort = `${address.slice(0, 6)}...${address.slice(-4)}`;
|
|
135
|
+
lines.push(`Trade Analysis for ${addrShort} (Hyperliquid)`);
|
|
136
|
+
if (days)
|
|
137
|
+
lines.push(`Period: last ${days} days`);
|
|
138
|
+
lines.push('');
|
|
139
|
+
if (stats.length === 0) {
|
|
140
|
+
lines.push(' No closed trades found.');
|
|
141
|
+
return lines.join('\n');
|
|
142
|
+
}
|
|
143
|
+
// Header
|
|
144
|
+
const header = ' Coin Trades Win% Gross P&L Fees Net P&L';
|
|
145
|
+
lines.push(header);
|
|
146
|
+
lines.push(' ' + '─'.repeat(header.length - 2));
|
|
147
|
+
let totalTrades = 0;
|
|
148
|
+
let totalWins = 0;
|
|
149
|
+
let totalGross = 0;
|
|
150
|
+
let totalFees = 0;
|
|
151
|
+
let totalNet = 0;
|
|
152
|
+
for (const s of stats) {
|
|
153
|
+
const winPct = s.trades > 0 ? Math.round((s.wins / s.trades) * 100) : 0;
|
|
154
|
+
const feeStr = `-$${s.fees.toFixed(2)}`;
|
|
155
|
+
const line = ` ${s.coin.padEnd(12)} ${String(s.trades).padStart(6)} ${String(winPct + '%').padStart(4)} ${formatUsd(s.grossPnl).padStart(12)} ${feeStr.padStart(8)} ${formatUsd(s.netPnl).padStart(12)}`;
|
|
156
|
+
lines.push(line);
|
|
157
|
+
totalTrades += s.trades;
|
|
158
|
+
totalWins += s.wins;
|
|
159
|
+
totalGross += s.grossPnl;
|
|
160
|
+
totalFees += s.fees;
|
|
161
|
+
totalNet += s.netPnl;
|
|
162
|
+
}
|
|
163
|
+
lines.push(' ' + '─'.repeat(header.length - 2));
|
|
164
|
+
const totalWinPct = totalTrades > 0 ? Math.round((totalWins / totalTrades) * 100) : 0;
|
|
165
|
+
const totalFeeStr = `-$${totalFees.toFixed(2)}`;
|
|
166
|
+
lines.push(` ${'Total'.padEnd(12)} ${String(totalTrades).padStart(6)} ${String(totalWinPct + '%').padStart(4)} ${formatUsd(totalGross).padStart(12)} ${totalFeeStr.padStart(8)} ${formatUsd(totalNet).padStart(12)}`);
|
|
167
|
+
lines.push('');
|
|
168
|
+
// Top winner / loser
|
|
169
|
+
const best = stats.reduce((a, b) => a.netPnl > b.netPnl ? a : b);
|
|
170
|
+
const worst = stats.reduce((a, b) => a.netPnl < b.netPnl ? a : b);
|
|
171
|
+
if (best.netPnl > 0)
|
|
172
|
+
lines.push(` Best: ${best.coin} ${formatUsd(best.netPnl)}`);
|
|
173
|
+
if (worst.netPnl < 0)
|
|
174
|
+
lines.push(` Worst: ${worst.coin} ${formatUsd(worst.netPnl)}`);
|
|
175
|
+
return lines.join('\n');
|
|
176
|
+
}
|
|
177
|
+
function formatCoinDetail(fills, coin) {
|
|
178
|
+
const coinFills = fills
|
|
179
|
+
.filter(f => f.coin.toUpperCase() === coin.toUpperCase())
|
|
180
|
+
.filter(f => parseFloat(f.closedPnl) !== 0);
|
|
181
|
+
if (coinFills.length === 0) {
|
|
182
|
+
return `No closed trades found for ${coin}.`;
|
|
183
|
+
}
|
|
184
|
+
const lines = [];
|
|
185
|
+
lines.push(`${coin} Trade History`);
|
|
186
|
+
lines.push('');
|
|
187
|
+
lines.push(' Date Dir Size Price P&L Fee');
|
|
188
|
+
lines.push(' ' + '─'.repeat(78));
|
|
189
|
+
for (const f of coinFills) {
|
|
190
|
+
const date = new Date(f.time).toISOString().replace('T', ' ').slice(0, 19);
|
|
191
|
+
const pnl = parseFloat(f.closedPnl);
|
|
192
|
+
const fee = parseFloat(f.fee);
|
|
193
|
+
const line = ` ${date} ${f.dir.padEnd(14)} ${parseFloat(f.sz).toFixed(4).padStart(10)} ${parseFloat(f.px).toFixed(2).padStart(10)} ${formatUsd(pnl).padStart(9)} ${formatUsd(-fee).padStart(8)}`;
|
|
194
|
+
lines.push(line);
|
|
195
|
+
}
|
|
196
|
+
return lines.join('\n');
|
|
197
|
+
}
|
|
198
|
+
function showHelp() {
|
|
199
|
+
console.log(`
|
|
200
|
+
invizi trades — Analyze past trades from Hyperliquid
|
|
201
|
+
|
|
202
|
+
Usage:
|
|
203
|
+
invizi trades [address] [options]
|
|
204
|
+
|
|
205
|
+
Options:
|
|
206
|
+
--days <n> Only show trades from the last N days
|
|
207
|
+
--coin <COIN> Show per-fill detail for a specific coin
|
|
208
|
+
--json Output as JSON (for piping to AI tools)
|
|
209
|
+
--help Show this help
|
|
210
|
+
|
|
211
|
+
Examples:
|
|
212
|
+
invizi trades P&L by coin (connected address)
|
|
213
|
+
invizi trades --days 30 Last 30 days only
|
|
214
|
+
invizi trades --coin SOL SOL fill-by-fill detail
|
|
215
|
+
invizi trades 0xABC...123 Analyze any public address
|
|
216
|
+
invizi trades --days 7 --json JSON output for AI tools
|
|
217
|
+
|
|
218
|
+
Data is read from the public Hyperliquid blockchain. No auth required.
|
|
219
|
+
`);
|
|
220
|
+
}
|
|
221
|
+
export async function trades(args) {
|
|
222
|
+
const { values, positionals } = parseArgs({
|
|
223
|
+
args,
|
|
224
|
+
options: {
|
|
225
|
+
days: { type: 'string', short: 'd' },
|
|
226
|
+
coin: { type: 'string', short: 'c' },
|
|
227
|
+
json: { type: 'boolean', default: false },
|
|
228
|
+
help: { type: 'boolean', short: 'h', default: false },
|
|
229
|
+
},
|
|
230
|
+
allowPositionals: true,
|
|
231
|
+
strict: false,
|
|
232
|
+
});
|
|
233
|
+
if (values.help) {
|
|
234
|
+
showHelp();
|
|
235
|
+
return 0;
|
|
236
|
+
}
|
|
237
|
+
const config = loadConfig();
|
|
238
|
+
const address = positionals.find(a => /^0x[0-9a-fA-F]{40}$/.test(a)) || config.address;
|
|
239
|
+
if (!address) {
|
|
240
|
+
console.error('No address found. Run: invizi connect <address>');
|
|
241
|
+
console.error('Or pass directly: invizi trades 0x...');
|
|
242
|
+
return 1;
|
|
243
|
+
}
|
|
244
|
+
const days = typeof values.days === 'string' ? parseInt(values.days, 10) : null;
|
|
245
|
+
const coinFilter = typeof values.coin === 'string' ? values.coin.toUpperCase() : null;
|
|
246
|
+
const jsonMode = values.json === true;
|
|
247
|
+
// Calculate start time
|
|
248
|
+
const now = Date.now();
|
|
249
|
+
const startTime = days ? now - days * 24 * 60 * 60 * 1000 : 0;
|
|
250
|
+
const addrShort = `${address.slice(0, 6)}...${address.slice(-4)}`;
|
|
251
|
+
if (!jsonMode) {
|
|
252
|
+
console.log(`Fetching trades for ${addrShort}...`);
|
|
253
|
+
}
|
|
254
|
+
try {
|
|
255
|
+
const fills = await fetchAllFills(address, startTime);
|
|
256
|
+
if (fills.length === 0) {
|
|
257
|
+
console.log('No trades found.');
|
|
258
|
+
return 0;
|
|
259
|
+
}
|
|
260
|
+
if (!jsonMode) {
|
|
261
|
+
console.log(` ${fills.length} fills loaded.\n`);
|
|
262
|
+
}
|
|
263
|
+
if (coinFilter) {
|
|
264
|
+
if (jsonMode) {
|
|
265
|
+
const coinFills = fills.filter(f => f.coin.toUpperCase() === coinFilter);
|
|
266
|
+
const stats = computeStats(coinFills);
|
|
267
|
+
console.log(JSON.stringify(stats, null, 2));
|
|
268
|
+
}
|
|
269
|
+
else {
|
|
270
|
+
console.log(formatCoinDetail(fills, coinFilter));
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
else {
|
|
274
|
+
const stats = computeStats(fills);
|
|
275
|
+
if (jsonMode) {
|
|
276
|
+
console.log(JSON.stringify(stats, null, 2));
|
|
277
|
+
}
|
|
278
|
+
else {
|
|
279
|
+
console.log(formatTable(stats, address, days));
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return 0;
|
|
283
|
+
}
|
|
284
|
+
catch (error) {
|
|
285
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
286
|
+
console.error(`Error: ${message}`);
|
|
287
|
+
return 1;
|
|
288
|
+
}
|
|
289
|
+
}
|
package/dist/invizi.js
CHANGED
|
@@ -3,20 +3,27 @@ import { auth, getAuthorizationHeader } from './auth.js';
|
|
|
3
3
|
import { connect } from './connect.js';
|
|
4
4
|
import { getApiUrl, getConfigPath, loadConfig, redactConfig } from './config.js';
|
|
5
5
|
import { setup } from './setup.js';
|
|
6
|
+
import { trades } from './commands/index.js';
|
|
6
7
|
function showLocalHelp() {
|
|
7
8
|
console.log(`
|
|
8
9
|
Invizi CLI
|
|
9
10
|
|
|
10
|
-
|
|
11
|
-
invizi
|
|
12
|
-
invizi
|
|
13
|
-
invizi
|
|
14
|
-
invizi
|
|
15
|
-
invizi
|
|
11
|
+
Data:
|
|
12
|
+
invizi context Market regime, macro, yields, options, HLP
|
|
13
|
+
invizi context -c SOL + coin data, flow, positions, trade history
|
|
14
|
+
invizi scout <COIN> Quick coin scan (price, funding, RSI, OBI)
|
|
15
|
+
invizi market BTC/ETH/SOL prices, fear/greed, funding extremes
|
|
16
|
+
invizi trades Analyze past trades — P&L by coin (local)
|
|
17
|
+
|
|
18
|
+
Account:
|
|
19
|
+
invizi auth login Login
|
|
20
|
+
invizi auth status Show current identity
|
|
21
|
+
invizi connect <address> Connect a wallet address
|
|
22
|
+
|
|
23
|
+
Setup:
|
|
24
|
+
invizi setup Configure API + install AI skills
|
|
16
25
|
invizi config Show current config
|
|
17
26
|
invizi version Show CLI version
|
|
18
|
-
|
|
19
|
-
Run invizi --help after login for the remote command list.
|
|
20
27
|
`);
|
|
21
28
|
}
|
|
22
29
|
async function executeProtocol(args, authHeader) {
|
|
@@ -74,13 +81,12 @@ async function showRemoteHelp(authHeader) {
|
|
|
74
81
|
console.log('No commands available for this user.');
|
|
75
82
|
return;
|
|
76
83
|
}
|
|
77
|
-
console.log('Invizi Remote Commands\n');
|
|
78
84
|
for (const cmd of commands) {
|
|
79
85
|
const name = Array.isArray(cmd.tokens) ? cmd.tokens.join(' ') : cmd.id || '(unknown)';
|
|
80
86
|
const desc = cmd.description || '';
|
|
81
|
-
console.log(` ${name.padEnd(
|
|
87
|
+
console.log(` invizi ${name.padEnd(22)} ${desc}`);
|
|
82
88
|
}
|
|
83
|
-
console.log('
|
|
89
|
+
console.log('');
|
|
84
90
|
}
|
|
85
91
|
async function executeRemote(args, options = {}) {
|
|
86
92
|
const authHeader = await getAuthorizationHeader();
|
|
@@ -127,6 +133,8 @@ export async function main(rawArgs = process.argv.slice(2)) {
|
|
|
127
133
|
return auth(args.slice(1));
|
|
128
134
|
if (command === 'connect')
|
|
129
135
|
return connect(args.slice(1));
|
|
136
|
+
if (command === 'trades')
|
|
137
|
+
return trades(args.slice(1));
|
|
130
138
|
if (command === 'setup')
|
|
131
139
|
return setup(args.slice(1));
|
|
132
140
|
if (command === 'config') {
|