@invizi/cli 0.1.4 → 0.1.6

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.
@@ -0,0 +1 @@
1
+ export { trades } from './trades.js';
@@ -0,0 +1,253 @@
1
+ import { loadConfig } from '../config.js';
2
+ const HLP_API = 'https://api.hyperliquid.xyz/info';
3
+ const MAX_FILLS_PER_REQUEST = 2000;
4
+ const REQUEST_DELAY_MS = 200;
5
+ async function fetchFills(address, startTime, endTime) {
6
+ const body = {
7
+ type: 'userFillsByTime',
8
+ user: address,
9
+ startTime,
10
+ aggregateByTime: true,
11
+ };
12
+ if (endTime)
13
+ body.endTime = endTime;
14
+ const res = await fetch(HLP_API, {
15
+ method: 'POST',
16
+ headers: { 'Content-Type': 'application/json' },
17
+ body: JSON.stringify(body),
18
+ });
19
+ if (!res.ok) {
20
+ throw new Error(`Hyperliquid API error: ${res.status}`);
21
+ }
22
+ return (await res.json());
23
+ }
24
+ async function fetchAllFills(address, startTime) {
25
+ const allFills = [];
26
+ let cursor = startTime;
27
+ while (true) {
28
+ const fills = await fetchFills(address, cursor);
29
+ if (fills.length === 0)
30
+ break;
31
+ allFills.push(...fills);
32
+ if (fills.length < MAX_FILLS_PER_REQUEST)
33
+ break;
34
+ // Next page: start after last fill's time
35
+ cursor = fills[fills.length - 1].time + 1;
36
+ // Rate limit courtesy
37
+ await new Promise(r => setTimeout(r, REQUEST_DELAY_MS));
38
+ }
39
+ return allFills;
40
+ }
41
+ function computeStats(fills) {
42
+ const byCoin = new Map();
43
+ for (const fill of fills) {
44
+ const existing = byCoin.get(fill.coin) || [];
45
+ existing.push(fill);
46
+ byCoin.set(fill.coin, existing);
47
+ }
48
+ const stats = [];
49
+ for (const [coin, coinFills] of byCoin) {
50
+ let grossPnl = 0;
51
+ let fees = 0;
52
+ let volume = 0;
53
+ let trades = 0;
54
+ let wins = 0;
55
+ let losses = 0;
56
+ // Track trade cycles: accumulate closedPnl until position flips through zero
57
+ let cyclePnl = 0;
58
+ let inTrade = false;
59
+ for (const fill of coinFills) {
60
+ const pnl = parseFloat(fill.closedPnl);
61
+ const fee = parseFloat(fill.fee);
62
+ const size = parseFloat(fill.sz);
63
+ const price = parseFloat(fill.px);
64
+ const startPos = parseFloat(fill.startPosition);
65
+ grossPnl += pnl;
66
+ fees += fee;
67
+ volume += size * price;
68
+ if (pnl !== 0) {
69
+ // This is a closing fill
70
+ cyclePnl += pnl - fee;
71
+ // Check if position went to zero (trade cycle complete)
72
+ const endPos = fill.side === 'B'
73
+ ? startPos + size
74
+ : startPos - size;
75
+ if (Math.abs(endPos) < 0.0000001 || Math.sign(endPos) !== Math.sign(startPos)) {
76
+ // Position closed or flipped
77
+ trades++;
78
+ if (cyclePnl > 0)
79
+ wins++;
80
+ else
81
+ losses++;
82
+ cyclePnl = 0;
83
+ inTrade = false;
84
+ }
85
+ else {
86
+ inTrade = true;
87
+ }
88
+ }
89
+ else if (!inTrade) {
90
+ inTrade = true;
91
+ }
92
+ }
93
+ // If there's an unclosed cycle with realized P&L, count it
94
+ if (inTrade && cyclePnl !== 0) {
95
+ trades++;
96
+ if (cyclePnl > 0)
97
+ wins++;
98
+ else
99
+ losses++;
100
+ }
101
+ // Fallback: if no trade cycles detected but there's P&L, count closing fills
102
+ if (trades === 0 && grossPnl !== 0) {
103
+ const closingFills = coinFills.filter(f => parseFloat(f.closedPnl) !== 0);
104
+ trades = closingFills.length;
105
+ wins = closingFills.filter(f => parseFloat(f.closedPnl) > 0).length;
106
+ losses = closingFills.filter(f => parseFloat(f.closedPnl) <= 0).length;
107
+ }
108
+ if (trades > 0 || grossPnl !== 0) {
109
+ stats.push({
110
+ coin,
111
+ trades,
112
+ wins,
113
+ losses,
114
+ grossPnl,
115
+ fees,
116
+ netPnl: grossPnl - fees,
117
+ volume,
118
+ lastTrade: coinFills[coinFills.length - 1].time,
119
+ });
120
+ }
121
+ }
122
+ // Sort by absolute net P&L descending
123
+ stats.sort((a, b) => Math.abs(b.netPnl) - Math.abs(a.netPnl));
124
+ return stats;
125
+ }
126
+ function formatUsd(n) {
127
+ if (n >= 0)
128
+ return `+$${n.toFixed(2)}`;
129
+ return `-$${Math.abs(n).toFixed(2)}`;
130
+ }
131
+ function formatTable(stats, address, days) {
132
+ const lines = [];
133
+ const addrShort = `${address.slice(0, 6)}...${address.slice(-4)}`;
134
+ lines.push(`Trade Analysis for ${addrShort} (Hyperliquid)`);
135
+ if (days)
136
+ lines.push(`Period: last ${days} days`);
137
+ lines.push('');
138
+ if (stats.length === 0) {
139
+ lines.push(' No closed trades found.');
140
+ return lines.join('\n');
141
+ }
142
+ // Header
143
+ const header = ' Coin Trades Win% Gross P&L Fees Net P&L';
144
+ lines.push(header);
145
+ lines.push(' ' + '─'.repeat(header.length - 2));
146
+ let totalTrades = 0;
147
+ let totalWins = 0;
148
+ let totalGross = 0;
149
+ let totalFees = 0;
150
+ let totalNet = 0;
151
+ for (const s of stats) {
152
+ const winPct = s.trades > 0 ? Math.round((s.wins / s.trades) * 100) : 0;
153
+ const feeStr = `-$${s.fees.toFixed(2)}`;
154
+ 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)}`;
155
+ lines.push(line);
156
+ totalTrades += s.trades;
157
+ totalWins += s.wins;
158
+ totalGross += s.grossPnl;
159
+ totalFees += s.fees;
160
+ totalNet += s.netPnl;
161
+ }
162
+ lines.push(' ' + '─'.repeat(header.length - 2));
163
+ const totalWinPct = totalTrades > 0 ? Math.round((totalWins / totalTrades) * 100) : 0;
164
+ const totalFeeStr = `-$${totalFees.toFixed(2)}`;
165
+ lines.push(` ${'Total'.padEnd(12)} ${String(totalTrades).padStart(6)} ${String(totalWinPct + '%').padStart(4)} ${formatUsd(totalGross).padStart(12)} ${totalFeeStr.padStart(8)} ${formatUsd(totalNet).padStart(12)}`);
166
+ lines.push('');
167
+ // Top winner / loser
168
+ const best = stats.reduce((a, b) => a.netPnl > b.netPnl ? a : b);
169
+ const worst = stats.reduce((a, b) => a.netPnl < b.netPnl ? a : b);
170
+ if (best.netPnl > 0)
171
+ lines.push(` Best: ${best.coin} ${formatUsd(best.netPnl)}`);
172
+ if (worst.netPnl < 0)
173
+ lines.push(` Worst: ${worst.coin} ${formatUsd(worst.netPnl)}`);
174
+ return lines.join('\n');
175
+ }
176
+ function formatCoinDetail(fills, coin) {
177
+ const coinFills = fills
178
+ .filter(f => f.coin.toUpperCase() === coin.toUpperCase())
179
+ .filter(f => parseFloat(f.closedPnl) !== 0);
180
+ if (coinFills.length === 0) {
181
+ return `No closed trades found for ${coin}.`;
182
+ }
183
+ const lines = [];
184
+ lines.push(`${coin} Trade History`);
185
+ lines.push('');
186
+ lines.push(' Date Dir Size Price P&L Fee');
187
+ lines.push(' ' + '─'.repeat(78));
188
+ for (const f of coinFills) {
189
+ const date = new Date(f.time).toISOString().replace('T', ' ').slice(0, 19);
190
+ const pnl = parseFloat(f.closedPnl);
191
+ const fee = parseFloat(f.fee);
192
+ 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)}`;
193
+ lines.push(line);
194
+ }
195
+ return lines.join('\n');
196
+ }
197
+ export async function trades(args) {
198
+ const config = loadConfig();
199
+ const address = args.find(a => /^0x[0-9a-fA-F]{40}$/.test(a)) || config.address;
200
+ if (!address) {
201
+ console.error('No address found. Run: invizi connect <address>');
202
+ console.error('Or pass directly: invizi trades 0x...');
203
+ return 1;
204
+ }
205
+ // Parse flags
206
+ const daysIdx = args.indexOf('--days');
207
+ const days = daysIdx !== -1 ? parseInt(args[daysIdx + 1], 10) : null;
208
+ const coinIdx = args.indexOf('--coin');
209
+ const coinFilter = coinIdx !== -1 ? args[coinIdx + 1]?.toUpperCase() : null;
210
+ const jsonMode = args.includes('--json');
211
+ // Calculate start time
212
+ const now = Date.now();
213
+ const startTime = days ? now - days * 24 * 60 * 60 * 1000 : 0;
214
+ const addrShort = `${address.slice(0, 6)}...${address.slice(-4)}`;
215
+ if (!jsonMode) {
216
+ console.log(`Fetching trades for ${addrShort}...`);
217
+ }
218
+ try {
219
+ const fills = await fetchAllFills(address, startTime);
220
+ if (fills.length === 0) {
221
+ console.log('No trades found.');
222
+ return 0;
223
+ }
224
+ if (!jsonMode) {
225
+ console.log(` ${fills.length} fills loaded.\n`);
226
+ }
227
+ if (coinFilter) {
228
+ if (jsonMode) {
229
+ const coinFills = fills.filter(f => f.coin.toUpperCase() === coinFilter);
230
+ const stats = computeStats(coinFills);
231
+ console.log(JSON.stringify(stats, null, 2));
232
+ }
233
+ else {
234
+ console.log(formatCoinDetail(fills, coinFilter));
235
+ }
236
+ }
237
+ else {
238
+ const stats = computeStats(fills);
239
+ if (jsonMode) {
240
+ console.log(JSON.stringify(stats, null, 2));
241
+ }
242
+ else {
243
+ console.log(formatTable(stats, address, days));
244
+ }
245
+ }
246
+ return 0;
247
+ }
248
+ catch (error) {
249
+ const message = error instanceof Error ? error.message : String(error);
250
+ console.error(`Error: ${message}`);
251
+ return 1;
252
+ }
253
+ }
package/dist/invizi.js CHANGED
@@ -3,6 +3,7 @@ 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
@@ -12,6 +13,7 @@ Commands:
12
13
  invizi auth status Show current auth identity
13
14
  invizi auth logout Clear local Auth0 session
14
15
  invizi connect <address> Connect a wallet address (auto-detects exchange)
16
+ invizi trades Analyze past trades (P&L by coin)
15
17
  invizi setup Install skills into your AI tool
16
18
  invizi config Show current config
17
19
  invizi version Show CLI version
@@ -103,6 +105,15 @@ async function executeRemote(args, options = {}) {
103
105
  export async function main(rawArgs = process.argv.slice(2)) {
104
106
  const args = rawArgs;
105
107
  const command = args[0];
108
+ // First-run: if no config exists and user isn't running setup/version/config, prompt
109
+ if (command && !['setup', 'version', 'config', '--version', '-v', '--help', '-h'].includes(command)) {
110
+ const config = loadConfig();
111
+ if (!config.apiUrl) {
112
+ console.log('First run detected. Running setup...\n');
113
+ await setup([]);
114
+ console.log('');
115
+ }
116
+ }
106
117
  if (!command || command === '--help' || command === '-h') {
107
118
  showLocalHelp();
108
119
  const header = await getAuthorizationHeader();
@@ -118,6 +129,8 @@ export async function main(rawArgs = process.argv.slice(2)) {
118
129
  return auth(args.slice(1));
119
130
  if (command === 'connect')
120
131
  return connect(args.slice(1));
132
+ if (command === 'trades')
133
+ return trades(args.slice(1));
121
134
  if (command === 'setup')
122
135
  return setup(args.slice(1));
123
136
  if (command === 'config') {
package/dist/setup.js CHANGED
@@ -89,6 +89,16 @@ export async function setup(args) {
89
89
  }
90
90
  const skills = (await listRes.json());
91
91
  for (const name of skills) {
92
+ const skillPath = join(skillsDir, name, 'SKILL.md');
93
+ const exists = existsSync(skillPath);
94
+ if (exists) {
95
+ console.log(` [exists] ${name} — overwrite? (y/n) `);
96
+ const answer = await promptYN();
97
+ if (!answer) {
98
+ console.log(` [skip] ${name}`);
99
+ continue;
100
+ }
101
+ }
92
102
  const res = await fetch(`${SKILLS_URL}/${name}/SKILL.md`);
93
103
  if (!res.ok) {
94
104
  console.error(` [x] ${name} (download failed)`);
@@ -98,7 +108,7 @@ export async function setup(args) {
98
108
  if (!dryRun) {
99
109
  const dir = join(skillsDir, name);
100
110
  mkdirSync(dir, { recursive: true });
101
- writeFileSync(join(dir, 'SKILL.md'), content);
111
+ writeFileSync(skillPath, content);
102
112
  }
103
113
  console.log(` [ok] ${name}`);
104
114
  }
@@ -107,8 +117,27 @@ export async function setup(args) {
107
117
  console.log('Dry run complete. No files written.');
108
118
  }
109
119
  else {
110
- console.log(`Installed ${skills.length} skills to ${skillsDir}`);
120
+ console.log(`Skills installed to ${skillsDir}`);
111
121
  console.log('Try /invizi-trade in your AI tool.');
112
122
  }
113
123
  return 0;
114
124
  }
125
+ function promptYN() {
126
+ return new Promise(resolve => {
127
+ const { stdin, stdout } = process;
128
+ if (!stdin.isTTY) {
129
+ // Non-interactive (CI, piped) — don't overwrite by default
130
+ resolve(false);
131
+ return;
132
+ }
133
+ stdin.setRawMode(true);
134
+ stdin.resume();
135
+ stdin.once('data', (data) => {
136
+ stdin.setRawMode(false);
137
+ stdin.pause();
138
+ const key = data.toString().toLowerCase();
139
+ stdout.write(key === 'y' ? 'yes\n' : 'no\n');
140
+ resolve(key === 'y');
141
+ });
142
+ });
143
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@invizi/cli",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Invizi CLI",
5
5
  "type": "module",
6
6
  "bin": {