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/.gitattributes +2 -0
- package/LICENSE +21 -0
- package/README.md +54 -0
- package/assets/.gitkeep +0 -0
- package/assets/dashboard.png +0 -0
- package/cache.json +3899 -0
- package/config.json +9 -0
- package/package.json +29 -0
- package/src/dataService.js +355 -0
- package/src/index.js +473 -0
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
|
+
});
|