stonks-dashboard 1.0.0

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/src/index.js ADDED
@@ -0,0 +1,473 @@
1
+ #!/usr/bin/env node
2
+ import blessed from 'blessed';
3
+ import contrib from 'blessed-contrib';
4
+ import chalk from 'chalk';
5
+ import { readFileSync } from 'fs';
6
+ import { DataService } from './dataService.js';
7
+
8
+ class StonksDashboard {
9
+ constructor() {
10
+ this.config = JSON.parse(readFileSync('./config.json', 'utf-8'));
11
+ this.dataService = new DataService();
12
+ this.assetsData = [];
13
+ this.prevAssetsData = [];
14
+ this.flashIndices = new Set();
15
+ this.selectedIndex = 0;
16
+ this.isLoading = true;
17
+ this.connectionError = false;
18
+
19
+ // Time periods: 1D, 7D, 30D, 90D
20
+ this.periods = [
21
+ { label: '1D', days: 1 },
22
+ { label: '7D', days: 7 },
23
+ { label: '30D', days: 30 },
24
+ { label: '90D', days: 90 }
25
+ ];
26
+ this.currentPeriodIndex = 1; // Default 7D
27
+
28
+ this.initScreen();
29
+ this.initWidgets();
30
+ this.setupKeyHandlers();
31
+ }
32
+
33
+ initScreen() {
34
+ this.screen = blessed.screen({
35
+ smartCSR: true,
36
+ title: 'STONKS DASHBOARD',
37
+ fullUnicode: true
38
+ });
39
+
40
+ this.screen.key(['escape', 'q', 'C-c'], () => {
41
+ return process.exit(0);
42
+ });
43
+ }
44
+
45
+ initWidgets() {
46
+ const grid = new contrib.grid({
47
+ rows: 12,
48
+ cols: 12,
49
+ screen: this.screen
50
+ });
51
+
52
+ // Watchlist table - left column
53
+ this.watchlistTable = grid.set(0, 0, 12, 4, contrib.table, {
54
+ keys: false,
55
+ vi: false,
56
+ mouse: false,
57
+ interactive: false,
58
+ label: ' WATCHLIST ',
59
+ border: { type: 'line', fg: 'cyan' },
60
+ fg: 'white',
61
+ columnSpacing: 1,
62
+ columnWidth: [8, 12, 10]
63
+ });
64
+
65
+ // Trend chart - top right
66
+ this.trendChart = grid.set(0, 4, 7, 8, contrib.line, {
67
+ label: ' PRICE TREND (7D) ',
68
+ border: { type: 'line', fg: 'cyan' },
69
+ style: {
70
+ line: 'green',
71
+ text: 'white',
72
+ baseline: 'white',
73
+ border: { fg: 'cyan' }
74
+ },
75
+ showLegend: false,
76
+ xPadding: 3,
77
+ yPadding: 1,
78
+ wholeNumbersOnly: false,
79
+ minY: null // Auto-scale, don't start at 0
80
+ });
81
+
82
+ // Details box - bottom right
83
+ this.detailsBox = grid.set(7, 4, 5, 8, blessed.box, {
84
+ label: ' DETAILS ',
85
+ border: { type: 'line', fg: 'cyan' },
86
+ style: {
87
+ border: { fg: 'cyan' }
88
+ },
89
+ tags: true,
90
+ content: ' '
91
+ });
92
+
93
+ // Status bar at bottom
94
+ this.statusBar = blessed.box({
95
+ bottom: 0,
96
+ left: 0,
97
+ width: '100%',
98
+ height: 1,
99
+ style: {
100
+ fg: 'cyan',
101
+ bg: 'black'
102
+ },
103
+ tags: true,
104
+ content: ' Loading...'
105
+ });
106
+ this.screen.append(this.statusBar);
107
+
108
+ // Loading spinner
109
+ this.loadingSpinner = blessed.loading({
110
+ top: 'center',
111
+ left: 'center',
112
+ height: 5,
113
+ width: 40,
114
+ border: { type: 'line', fg: 'cyan' },
115
+ style: { border: { fg: 'cyan' } }
116
+ });
117
+ this.screen.append(this.loadingSpinner);
118
+ }
119
+
120
+ setupKeyHandlers() {
121
+ // Arrow up
122
+ this.screen.key(['up', 'k'], () => {
123
+ if (this.assetsData.length === 0) return;
124
+ if (this.selectedIndex > 0) {
125
+ this.selectedIndex--;
126
+ this.refreshDisplay();
127
+ }
128
+ });
129
+
130
+ // Arrow down
131
+ this.screen.key(['down', 'j'], () => {
132
+ if (this.assetsData.length === 0) return;
133
+ if (this.selectedIndex < this.assetsData.length - 1) {
134
+ this.selectedIndex++;
135
+ this.refreshDisplay();
136
+ }
137
+ });
138
+
139
+ // Period switch keys
140
+ this.screen.key(['1'], () => this.switchPeriod(0));
141
+ this.screen.key(['2'], () => this.switchPeriod(1));
142
+ this.screen.key(['3'], () => this.switchPeriod(2));
143
+ this.screen.key(['4'], () => this.switchPeriod(3));
144
+ }
145
+
146
+ refreshDisplay() {
147
+ this.updateWatchlistTable();
148
+ this.updateChartPanel();
149
+ this.updateDetailsPanel();
150
+ this.updateStatusBar();
151
+ this.screen.render();
152
+ }
153
+
154
+ async switchPeriod(periodIndex) {
155
+ if (periodIndex < 0 || periodIndex >= this.periods.length) return;
156
+ if (this.currentPeriodIndex === periodIndex) return;
157
+
158
+ this.currentPeriodIndex = periodIndex;
159
+
160
+ this.loadingSpinner.load('Fetching data...');
161
+ this.screen.render();
162
+
163
+ await this.fetchData();
164
+
165
+ this.loadingSpinner.stop();
166
+ this.refreshDisplay();
167
+ }
168
+
169
+ formatPrice(price) {
170
+ if (!price || isNaN(price)) return '$0.00';
171
+ if (price >= 1000) {
172
+ return `$${price.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
173
+ } else if (price >= 1) {
174
+ return `$${price.toFixed(2)}`;
175
+ } else {
176
+ return `$${price.toFixed(4)}`;
177
+ }
178
+ }
179
+
180
+ formatChange(change) {
181
+ if (!change || isNaN(change)) return '+0.00%';
182
+ const sign = change >= 0 ? '+' : '';
183
+ return `${sign}${change.toFixed(2)}%`;
184
+ }
185
+
186
+ getAssetCategory(asset) {
187
+ if (asset.type === 'crypto') return 'crypto';
188
+ if (['SPY', 'QQQ', 'VOO', 'VTI', 'IWM', 'DIA', 'ARKK', 'XLF', 'XLE', 'GLD', 'SLV'].includes(asset.symbol)) {
189
+ return 'etf';
190
+ }
191
+ return 'stock';
192
+ }
193
+
194
+ updateWatchlistTable() {
195
+ if (this.assetsData.length === 0) return;
196
+
197
+ const headers = ['SYMBOL', 'PRICE', 'CHANGE'];
198
+ const rows = [];
199
+
200
+ // Separate by type
201
+ const cryptos = this.assetsData.filter(a => this.getAssetCategory(a) === 'crypto');
202
+ const stocks = this.assetsData.filter(a => this.getAssetCategory(a) === 'stock');
203
+ const etfs = this.assetsData.filter(a => this.getAssetCategory(a) === 'etf');
204
+
205
+ const addSection = (title, assets) => {
206
+ if (assets.length === 0) return;
207
+ rows.push([chalk.cyan(title), '', '']);
208
+
209
+ for (const asset of assets) {
210
+ const isSelected = this.assetsData.indexOf(asset) === this.selectedIndex;
211
+ const prefix = isSelected ? '>' : ' ';
212
+ const symbol = `${prefix}${asset.symbol}`;
213
+ const price = this.formatPrice(asset.price);
214
+ const change = this.formatChange(asset.change);
215
+
216
+ if (isSelected) {
217
+ rows.push([
218
+ chalk.bgBlue.white(symbol.padEnd(7)),
219
+ chalk.bgBlue.white(price.padEnd(11)),
220
+ asset.change >= 0 ? chalk.bgBlue.green(change) : chalk.bgBlue.red(change)
221
+ ]);
222
+ } else {
223
+ rows.push([
224
+ chalk.white(symbol),
225
+ chalk.white(price),
226
+ asset.change >= 0 ? chalk.green(change) : chalk.red(change)
227
+ ]);
228
+ }
229
+ }
230
+ };
231
+
232
+ addSection('-- CRYPTO --', cryptos);
233
+ addSection('-- STOCKS --', stocks);
234
+ addSection('-- ETFs --', etfs);
235
+
236
+ this.watchlistTable.setData({ headers, data: rows });
237
+ }
238
+
239
+ updateChartPanel() {
240
+ if (this.assetsData.length === 0 || this.selectedIndex < 0) return;
241
+ if (this.selectedIndex >= this.assetsData.length) {
242
+ this.selectedIndex = this.assetsData.length - 1;
243
+ }
244
+
245
+ const asset = this.assetsData[this.selectedIndex];
246
+ if (!asset) return;
247
+
248
+ // Filter out null/undefined values for history
249
+ const rawHistory = asset.history || [];
250
+ const history = rawHistory.filter(v => v !== null && v !== undefined && !isNaN(v));
251
+
252
+ // Use timestamps if available and aligned
253
+ const rawTs = asset.timestamps || [];
254
+ const hasTimestamps = Array.isArray(rawTs) && rawTs.length === rawHistory.length;
255
+
256
+ if (history.length === 0) {
257
+ history.push(0);
258
+ }
259
+
260
+ const period = this.periods[this.currentPeriodIndex];
261
+ const len = history.length;
262
+
263
+ // Generate clean X-axis labels (prefer timestamps)
264
+ const numLabels = Math.min(10, len);
265
+ const step = Math.max(1, Math.floor(len / numLabels));
266
+
267
+ const x = [];
268
+ for (let i = 0; i < len; i++) {
269
+ const isTick = (i === 0 || i === len - 1 || i % step === 0);
270
+ if (!isTick) { x.push(' '); continue; }
271
+
272
+ if (hasTimestamps) {
273
+ const ts = rawTs[i];
274
+ const d = new Date(ts);
275
+ if (period.days === 1) {
276
+ const hh = String(d.getHours()).padStart(2, '0');
277
+ const mm = String(d.getMinutes()).padStart(2, '0');
278
+ x.push(`${hh}:${mm}`);
279
+ } else if (period.days <= 7) {
280
+ const m = String(d.getMonth() + 1).padStart(2, '0');
281
+ const day = String(d.getDate()).padStart(2, '0');
282
+ x.push(`${m}/${day}`);
283
+ } else {
284
+ const m = String(d.getMonth() + 1).padStart(2, '0');
285
+ x.push(`${m}`);
286
+ }
287
+ } else {
288
+ // Fallback: index-based labels
289
+ x.push(period.days === 1 ? `${i}h` : `${i + 1}`);
290
+ }
291
+ }
292
+
293
+ const lineColor = asset.change >= 0 ? 'green' : 'red';
294
+ const category = this.getAssetCategory(asset);
295
+ const typeLabel = category === 'crypto' ? 'CRYPTO' : (category === 'etf' ? 'ETF' : 'STOCK');
296
+
297
+ this.trendChart.setLabel(` ${asset.symbol} | ${typeLabel} | ${period.label} `);
298
+
299
+ // Calculate min/max for proper Y scaling (add 5% padding)
300
+ const minVal = Math.min(...history);
301
+ const maxVal = Math.max(...history);
302
+ const padding = (maxVal - minVal) * 0.05 || 1;
303
+
304
+ this.trendChart.options.minY = minVal - padding;
305
+ this.trendChart.options.maxY = maxVal + padding;
306
+
307
+ this.trendChart.setData([{
308
+ title: asset.symbol,
309
+ x: x,
310
+ y: history,
311
+ style: { line: lineColor }
312
+ }]);
313
+ }
314
+
315
+ formatNumber(num) {
316
+ if (!num || isNaN(num)) return 'N/A';
317
+ if (num >= 1e12) return `${(num / 1e12).toFixed(2)}T`;
318
+ if (num >= 1e9) return `${(num / 1e9).toFixed(2)}B`;
319
+ if (num >= 1e6) return `${(num / 1e6).toFixed(2)}M`;
320
+ if (num >= 1e3) return `${(num / 1e3).toFixed(2)}K`;
321
+ return num.toLocaleString();
322
+ }
323
+
324
+ updateDetailsPanel() {
325
+ if (this.assetsData.length === 0 || this.selectedIndex < 0) return;
326
+ if (this.selectedIndex >= this.assetsData.length) return;
327
+
328
+ const asset = this.assetsData[this.selectedIndex];
329
+ if (!asset) return;
330
+
331
+ const changeColor = asset.change >= 0 ? 'green' : 'red';
332
+ const changeText = this.formatChange(asset.change);
333
+
334
+ // Determine asset type label
335
+ const category = this.getAssetCategory(asset);
336
+ let typeLabel = 'STOCK';
337
+ let typeIcon = '[S]';
338
+ if (category === 'crypto') {
339
+ typeLabel = 'CRYPTO';
340
+ typeIcon = '[C]';
341
+ } else if (category === 'etf') {
342
+ typeLabel = 'ETF';
343
+ typeIcon = '[E]';
344
+ }
345
+
346
+ let content = '';
347
+
348
+ if (asset.type === 'crypto') {
349
+ // Crypto detailed view
350
+ content = `
351
+ {bold}{cyan-fg}${typeIcon} ${asset.symbol}{/cyan-fg}{/bold} {gray-fg}${typeLabel}{/gray-fg} ${asset.rank ? `#${asset.rank}` : ''}
352
+ ${'─'.repeat(38)}
353
+ {bold}Price{/bold} ${this.formatPrice(asset.price)}
354
+ {bold}24h{/bold} {${changeColor}-fg}${this.formatChange(asset.change24h || asset.change)}{/${changeColor}-fg}
355
+ {bold}Open{/bold} ${this.formatPrice(asset.open)}
356
+ ${'─'.repeat(38)}
357
+ {bold}High 24h{/bold} ${this.formatPrice(asset.high)}
358
+ {bold}Low 24h{/bold} ${this.formatPrice(asset.low)}
359
+ {bold}ATH{/bold} ${this.formatPrice(asset.high52w)}
360
+ {bold}ATL{/bold} ${this.formatPrice(asset.low52w)}
361
+ ${'─'.repeat(38)}
362
+ {bold}Mkt Cap{/bold} ${this.formatNumber(asset.marketCap)}
363
+ {bold}Volume 24h{/bold} ${this.formatNumber(asset.volume)}
364
+ {bold}Circ Supply{/bold} ${this.formatNumber(asset.circulatingSupply)}
365
+ ${'─'.repeat(38)}
366
+ ${asset.fromCache ? '{yellow-fg}[CACHE]{/yellow-fg}' : '{green-fg}[LIVE]{/green-fg}'} ${asset.error ? '{red-fg}[ERROR]{/red-fg}' : ''}
367
+ `;
368
+ } else {
369
+ // Stock/ETF detailed view
370
+ content = `
371
+ {bold}{cyan-fg}${typeIcon} ${asset.symbol}{/cyan-fg}{/bold} {gray-fg}${typeLabel}{/gray-fg}
372
+ ${'─'.repeat(38)}
373
+ {bold}Price{/bold} ${this.formatPrice(asset.price)}
374
+ {bold}Change{/bold} {${changeColor}-fg}${changeText}{/${changeColor}-fg}
375
+ {bold}Open{/bold} ${this.formatPrice(asset.open)}
376
+ {bold}Prev Close{/bold} ${this.formatPrice(asset.previousClose)}
377
+ ${'─'.repeat(38)}
378
+ {bold}High{/bold} ${this.formatPrice(asset.high)}
379
+ {bold}Low{/bold} ${this.formatPrice(asset.low)}
380
+ {bold}52wk High{/bold} ${this.formatPrice(asset.high52w)}
381
+ {bold}52wk Low{/bold} ${this.formatPrice(asset.low52w)}
382
+ ${'─'.repeat(38)}
383
+ {bold}Volume{/bold} ${this.formatNumber(asset.volume)}
384
+ {bold}Avg Vol{/bold} ${this.formatNumber(asset.avgVolume)}
385
+ {bold}Mkt Cap{/bold} ${this.formatNumber(asset.marketCap)}
386
+ {bold}P/E{/bold} ${asset.pe ? asset.pe.toFixed(2) : 'N/A'}
387
+ ${'─'.repeat(38)}
388
+ ${asset.fromCache ? '{yellow-fg}[CACHE]{/yellow-fg}' : '{green-fg}[LIVE]{/green-fg}'} ${asset.error ? '{red-fg}[ERROR]{/red-fg}' : ''}
389
+ `;
390
+ }
391
+
392
+ this.detailsBox.setContent(content);
393
+ }
394
+
395
+ updateStatusBar() {
396
+ const now = new Date().toLocaleTimeString();
397
+ const period = this.periods[this.currentPeriodIndex].label;
398
+ const assetCount = this.assetsData.length;
399
+ const selected = this.selectedIndex + 1;
400
+
401
+ const status = this.connectionError
402
+ ? '{yellow-fg}CACHED{/yellow-fg}'
403
+ : '{green-fg}LIVE{/green-fg}';
404
+
405
+ this.statusBar.setContent(
406
+ ` ${status} | ${selected}/${assetCount} | ${period} | ${now} | {cyan-fg}[1-4]{/cyan-fg} Period | {cyan-fg}[UP/DOWN]{/cyan-fg} Navigate | {cyan-fg}[q]{/cyan-fg} Quit`
407
+ );
408
+ }
409
+
410
+ async fetchData() {
411
+ try {
412
+ this.connectionError = false;
413
+ const period = this.periods[this.currentPeriodIndex];
414
+ const newData = await this.dataService.fetchAllAssets(
415
+ this.config.tickers,
416
+ this.config.cryptoIds,
417
+ period.days
418
+ );
419
+
420
+ // Compute flash indices
421
+ const prevBySymbol = new Map(this.prevAssetsData.map(a => [a.symbol, a]));
422
+ this.flashIndices.clear();
423
+ for (const asset of newData) {
424
+ const prev = prevBySymbol.get(asset.symbol);
425
+ if (prev && prev.price > 0) {
426
+ const deltaPct = Math.abs((asset.price - prev.price) / prev.price) * 100;
427
+ if (deltaPct >= 2) {
428
+ this.flashIndices.add(asset.symbol);
429
+ }
430
+ }
431
+ }
432
+
433
+ this.prevAssetsData = this.assetsData;
434
+ this.assetsData = newData;
435
+
436
+ // Clamp selected index
437
+ if (this.selectedIndex >= this.assetsData.length) {
438
+ this.selectedIndex = Math.max(0, this.assetsData.length - 1);
439
+ }
440
+
441
+ this.connectionError = this.assetsData.some(asset => asset.error);
442
+
443
+ } catch (error) {
444
+ this.connectionError = true;
445
+ }
446
+ }
447
+
448
+ async startGameLoop() {
449
+ await this.fetchData();
450
+
451
+ this.isLoading = false;
452
+ this.loadingSpinner.stop();
453
+ this.refreshDisplay();
454
+
455
+ // Update loop
456
+ setInterval(async () => {
457
+ await this.fetchData();
458
+ this.refreshDisplay();
459
+ }, this.config.updateInterval);
460
+ }
461
+
462
+ async start() {
463
+ this.loadingSpinner.load('Loading market data...');
464
+ this.screen.render();
465
+ await this.startGameLoop();
466
+ }
467
+ }
468
+
469
+ const dashboard = new StonksDashboard();
470
+ dashboard.start().catch(error => {
471
+ console.error('Fatal error:', error);
472
+ process.exit(1);
473
+ });