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/config.json
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "stonks-dashboard",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "A cyberpunk-style real-time financial monitor for the terminal",
|
|
5
|
+
"main": "src/index.js",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"stonks-dashboard": "./src/index.js"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"start": "node src/index.js",
|
|
12
|
+
"dev": "node src/index.js"
|
|
13
|
+
},
|
|
14
|
+
"keywords": [
|
|
15
|
+
"stocks",
|
|
16
|
+
"crypto",
|
|
17
|
+
"terminal",
|
|
18
|
+
"dashboard",
|
|
19
|
+
"TUI"
|
|
20
|
+
],
|
|
21
|
+
"author": "",
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"axios": "^1.6.2",
|
|
25
|
+
"blessed": "^0.1.81",
|
|
26
|
+
"blessed-contrib": "^4.11.0",
|
|
27
|
+
"chalk": "^5.3.0"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
|
3
|
+
|
|
4
|
+
// Global rate limiter for CoinGecko - sequential with delay
|
|
5
|
+
let lastCoinGeckoCall = 0;
|
|
6
|
+
const COINGECKO_DELAY = 5000; // 5 seconds between calls to avoid 429
|
|
7
|
+
|
|
8
|
+
// File cache settings
|
|
9
|
+
const CACHE_FILE = './cache.json';
|
|
10
|
+
const CACHE_TTL = 60 * 1000; // 1 minute cache validity
|
|
11
|
+
const DETAIL_TTL = 30 * 60 * 1000; // 30 minutes for crypto detail
|
|
12
|
+
|
|
13
|
+
async function waitForCoinGecko() {
|
|
14
|
+
const now = Date.now();
|
|
15
|
+
const elapsed = now - lastCoinGeckoCall;
|
|
16
|
+
if (elapsed < COINGECKO_DELAY) {
|
|
17
|
+
await new Promise(resolve => setTimeout(resolve, COINGECKO_DELAY - elapsed));
|
|
18
|
+
}
|
|
19
|
+
lastCoinGeckoCall = Date.now();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function axiosGetWithRetry(url, options, retries = 3, baseDelay = 1000) {
|
|
23
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
24
|
+
try {
|
|
25
|
+
await waitForCoinGecko();
|
|
26
|
+
return await axios.get(url, options);
|
|
27
|
+
} catch (e) {
|
|
28
|
+
const status = e.response?.status;
|
|
29
|
+
const shouldRetry = status === 429 || (status >= 500) || !status;
|
|
30
|
+
if (attempt < retries && shouldRetry) {
|
|
31
|
+
const jitter = Math.floor(Math.random() * 300);
|
|
32
|
+
const delay = baseDelay * Math.pow(2, attempt) + jitter;
|
|
33
|
+
await new Promise(r => setTimeout(r, delay));
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
throw e;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class DataService {
|
|
42
|
+
constructor() {
|
|
43
|
+
this.cache = new Map();
|
|
44
|
+
this.loadFileCache();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
loadFileCache() {
|
|
48
|
+
try {
|
|
49
|
+
if (existsSync(CACHE_FILE)) {
|
|
50
|
+
const data = JSON.parse(readFileSync(CACHE_FILE, 'utf-8'));
|
|
51
|
+
for (const [key, value] of Object.entries(data)) {
|
|
52
|
+
this.cache.set(key, value);
|
|
53
|
+
}
|
|
54
|
+
console.log(`[Cache] Loaded ${Object.keys(data).length} entries from file`);
|
|
55
|
+
}
|
|
56
|
+
} catch (error) {
|
|
57
|
+
console.error('[Cache] Error loading cache file:', error.message);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
saveFileCache() {
|
|
62
|
+
try {
|
|
63
|
+
const data = Object.fromEntries(this.cache);
|
|
64
|
+
writeFileSync(CACHE_FILE, JSON.stringify(data, null, 2));
|
|
65
|
+
} catch (error) {
|
|
66
|
+
console.error('[Cache] Error saving cache file:', error.message);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
isCacheValid(cacheKey, ttl = CACHE_TTL) {
|
|
71
|
+
const cached = this.cache.get(cacheKey);
|
|
72
|
+
if (!cached || !cached.timestamp) return false;
|
|
73
|
+
return (Date.now() - cached.timestamp) < ttl;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async fetchCryptoData(symbol, coinId, days = 7) {
|
|
77
|
+
const chartKey = `crypto-${symbol}-${days}`;
|
|
78
|
+
const detailKey = `detail-${coinId}`;
|
|
79
|
+
|
|
80
|
+
// Use cached chart if valid
|
|
81
|
+
if (this.isCacheValid(chartKey)) {
|
|
82
|
+
return { ...this.cache.get(chartKey), fromCache: true };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
// Fetch chart sequentially with delay
|
|
87
|
+
const chartResponse = await axiosGetWithRetry(`https://api.coingecko.com/api/v3/coins/${coinId}/market_chart`, {
|
|
88
|
+
params: { vs_currency: 'usd', days: days },
|
|
89
|
+
headers: { 'User-Agent': 'Mozilla/5.0' },
|
|
90
|
+
timeout: 10000
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// Build aligned timestamps + prices arrays, filtering invalid points
|
|
94
|
+
const pairs = chartResponse.data.prices || [];
|
|
95
|
+
const timestamps = [];
|
|
96
|
+
const prices = [];
|
|
97
|
+
for (const p of pairs) {
|
|
98
|
+
const ts = p?.[0];
|
|
99
|
+
const val = p?.[1];
|
|
100
|
+
if (val !== null && val !== undefined && !isNaN(val)) {
|
|
101
|
+
timestamps.push(typeof ts === 'number' ? ts : Date.now());
|
|
102
|
+
prices.push(val);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (prices.length === 0) prices.push(0);
|
|
106
|
+
const validPrices = prices.filter(p => p > 0);
|
|
107
|
+
|
|
108
|
+
// Try to use cached details (longer TTL)
|
|
109
|
+
let detailData = this.isCacheValid(detailKey, DETAIL_TTL) ? this.cache.get(detailKey) : null;
|
|
110
|
+
if (!detailData) {
|
|
111
|
+
// Space requests to avoid burst 429
|
|
112
|
+
try {
|
|
113
|
+
const detailResponse = await axiosGetWithRetry(`https://api.coingecko.com/api/v3/coins/${coinId}`, {
|
|
114
|
+
params: { localization: false, tickers: false, community_data: false, developer_data: false },
|
|
115
|
+
headers: { 'User-Agent': 'Mozilla/5.0' },
|
|
116
|
+
timeout: 10000
|
|
117
|
+
});
|
|
118
|
+
const md = detailResponse.data.market_data || {};
|
|
119
|
+
detailData = {
|
|
120
|
+
currentPrice: md.current_price?.usd || null,
|
|
121
|
+
change24h: md.price_change_percentage_24h || 0,
|
|
122
|
+
high24h: md.high_24h?.usd || null,
|
|
123
|
+
low24h: md.low_24h?.usd || null,
|
|
124
|
+
ath: md.ath?.usd || null,
|
|
125
|
+
atl: md.atl?.usd || null,
|
|
126
|
+
marketCap: md.market_cap?.usd || 0,
|
|
127
|
+
volume24h: md.total_volume?.usd || 0,
|
|
128
|
+
circulatingSupply: md.circulating_supply || 0,
|
|
129
|
+
totalSupply: md.total_supply || 0,
|
|
130
|
+
rank: detailResponse.data.market_cap_rank || 0,
|
|
131
|
+
timestamp: Date.now()
|
|
132
|
+
};
|
|
133
|
+
this.cache.set(detailKey, detailData);
|
|
134
|
+
this.saveFileCache();
|
|
135
|
+
} catch (e) {
|
|
136
|
+
// Details optional; continue with chart-only data
|
|
137
|
+
detailData = null;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const currentPrice = (detailData?.currentPrice ?? prices[prices.length - 1]) || 0;
|
|
142
|
+
const firstPrice = prices[0] || currentPrice;
|
|
143
|
+
const change = firstPrice > 0 ? ((currentPrice - firstPrice) / firstPrice) * 100 : 0;
|
|
144
|
+
|
|
145
|
+
const result = {
|
|
146
|
+
symbol,
|
|
147
|
+
type: 'crypto',
|
|
148
|
+
price: currentPrice,
|
|
149
|
+
change,
|
|
150
|
+
change24h: detailData?.change24h ?? 0,
|
|
151
|
+
history: prices,
|
|
152
|
+
timestamps,
|
|
153
|
+
// Extended
|
|
154
|
+
open: prices[0] || 0,
|
|
155
|
+
high: (detailData?.high24h ?? (validPrices.length > 0 ? Math.max(...validPrices) : 0)) || 0,
|
|
156
|
+
low: (detailData?.low24h ?? (validPrices.length > 0 ? Math.min(...validPrices) : 0)) || 0,
|
|
157
|
+
high52w: detailData?.ath ?? 0,
|
|
158
|
+
low52w: detailData?.atl ?? 0,
|
|
159
|
+
marketCap: detailData?.marketCap ?? 0,
|
|
160
|
+
volume: detailData?.volume24h ?? 0,
|
|
161
|
+
circulatingSupply: detailData?.circulatingSupply ?? 0,
|
|
162
|
+
totalSupply: detailData?.totalSupply ?? 0,
|
|
163
|
+
rank: detailData?.rank ?? 0,
|
|
164
|
+
timestamp: Date.now(),
|
|
165
|
+
error: false
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
this.cache.set(chartKey, result);
|
|
169
|
+
this.saveFileCache();
|
|
170
|
+
return result;
|
|
171
|
+
|
|
172
|
+
} catch (error) {
|
|
173
|
+
console.error(`[Crypto] ${symbol}: ${error.message}`);
|
|
174
|
+
|
|
175
|
+
if (this.cache.has(chartKey)) {
|
|
176
|
+
return { ...this.cache.get(chartKey), error: true, fromCache: true };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
symbol,
|
|
181
|
+
type: 'crypto',
|
|
182
|
+
price: 0,
|
|
183
|
+
change: 0,
|
|
184
|
+
change24h: 0,
|
|
185
|
+
history: [0],
|
|
186
|
+
open: 0,
|
|
187
|
+
high: 0,
|
|
188
|
+
low: 0,
|
|
189
|
+
high52w: 0,
|
|
190
|
+
low52w: 0,
|
|
191
|
+
marketCap: 0,
|
|
192
|
+
volume: 0,
|
|
193
|
+
circulatingSupply: 0,
|
|
194
|
+
totalSupply: 0,
|
|
195
|
+
rank: 0,
|
|
196
|
+
error: true
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async fetchStockData(symbol, days = 7) {
|
|
202
|
+
const cacheKey = `stock-${symbol}-${days}`;
|
|
203
|
+
|
|
204
|
+
// Return cached data if valid
|
|
205
|
+
if (this.isCacheValid(cacheKey)) {
|
|
206
|
+
return { ...this.cache.get(cacheKey), fromCache: true };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
let range = '7d';
|
|
210
|
+
let interval = '1d';
|
|
211
|
+
if (days <= 1) {
|
|
212
|
+
range = '1d';
|
|
213
|
+
interval = '1h';
|
|
214
|
+
} else if (days <= 7) {
|
|
215
|
+
range = '7d';
|
|
216
|
+
interval = '1d';
|
|
217
|
+
} else if (days <= 30) {
|
|
218
|
+
range = '1mo';
|
|
219
|
+
interval = '1d';
|
|
220
|
+
} else {
|
|
221
|
+
range = '3mo';
|
|
222
|
+
interval = '1d';
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
try {
|
|
226
|
+
// Fetch chart data
|
|
227
|
+
const response = await axios.get(`https://query1.finance.yahoo.com/v8/finance/chart/${symbol}`, {
|
|
228
|
+
params: { interval, range },
|
|
229
|
+
headers: { 'User-Agent': 'Mozilla/5.0' },
|
|
230
|
+
timeout: 10000
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
const result = response.data.chart.result[0];
|
|
234
|
+
const quote = result.indicators.quote[0];
|
|
235
|
+
const meta = result.meta;
|
|
236
|
+
|
|
237
|
+
// Align timestamps with close prices, filtering invalid points
|
|
238
|
+
const rawCloses = quote.close || [];
|
|
239
|
+
const rawTimestamps = result.timestamp || [];
|
|
240
|
+
const closePrices = [];
|
|
241
|
+
const timestamps = [];
|
|
242
|
+
for (let i = 0; i < rawCloses.length; i++) {
|
|
243
|
+
const val = rawCloses[i];
|
|
244
|
+
if (val !== null && val !== undefined && !isNaN(val)) {
|
|
245
|
+
closePrices.push(val);
|
|
246
|
+
const ts = rawTimestamps[i];
|
|
247
|
+
timestamps.push(typeof ts === 'number' ? ts * 1000 : Date.now());
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (closePrices.length === 0) closePrices.push(0);
|
|
252
|
+
|
|
253
|
+
const currentPrice = meta.regularMarketPrice || closePrices[closePrices.length - 1] || 0;
|
|
254
|
+
const previousClose = meta.chartPreviousClose || closePrices[0] || currentPrice;
|
|
255
|
+
const change = previousClose > 0 ? ((currentPrice - previousClose) / previousClose) * 100 : 0;
|
|
256
|
+
const validPrices = closePrices.filter(p => p > 0);
|
|
257
|
+
|
|
258
|
+
// Get first price of the day as open (approximation)
|
|
259
|
+
const openPrices = (quote.open || []).filter(p => p !== null && !isNaN(p));
|
|
260
|
+
const openPrice = openPrices[openPrices.length - 1] || previousClose;
|
|
261
|
+
|
|
262
|
+
const data = {
|
|
263
|
+
symbol,
|
|
264
|
+
type: 'stock',
|
|
265
|
+
price: currentPrice,
|
|
266
|
+
change,
|
|
267
|
+
change24h: change,
|
|
268
|
+
history: closePrices,
|
|
269
|
+
timestamps,
|
|
270
|
+
// Extended data
|
|
271
|
+
open: openPrice,
|
|
272
|
+
previousClose: previousClose,
|
|
273
|
+
high: meta.regularMarketDayHigh || (validPrices.length > 0 ? Math.max(...validPrices) : 0),
|
|
274
|
+
low: meta.regularMarketDayLow || (validPrices.length > 0 ? Math.min(...validPrices) : 0),
|
|
275
|
+
high52w: meta.fiftyTwoWeekHigh || 0,
|
|
276
|
+
low52w: meta.fiftyTwoWeekLow || 0,
|
|
277
|
+
marketCap: 0,
|
|
278
|
+
volume: meta.regularMarketVolume || 0,
|
|
279
|
+
avgVolume: 0,
|
|
280
|
+
pe: 0,
|
|
281
|
+
timestamp: Date.now(),
|
|
282
|
+
error: false
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
// Try to get additional quote data
|
|
286
|
+
try {
|
|
287
|
+
const quoteResponse = await axios.get(`https://query1.finance.yahoo.com/v7/finance/quote`, {
|
|
288
|
+
params: { symbols: symbol },
|
|
289
|
+
headers: { 'User-Agent': 'Mozilla/5.0' },
|
|
290
|
+
timeout: 5000
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
const quoteData = quoteResponse.data.quoteResponse?.result?.[0];
|
|
294
|
+
if (quoteData) {
|
|
295
|
+
data.marketCap = quoteData.marketCap || 0;
|
|
296
|
+
data.pe = quoteData.trailingPE || quoteData.forwardPE || 0;
|
|
297
|
+
data.avgVolume = quoteData.averageDailyVolume3Month || quoteData.averageDailyVolume10Day || 0;
|
|
298
|
+
data.high52w = quoteData.fiftyTwoWeekHigh || data.high52w;
|
|
299
|
+
data.low52w = quoteData.fiftyTwoWeekLow || data.low52w;
|
|
300
|
+
data.open = quoteData.regularMarketOpen || data.open;
|
|
301
|
+
}
|
|
302
|
+
} catch (e) {
|
|
303
|
+
// Quote data is optional, continue without it
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
this.cache.set(cacheKey, data);
|
|
307
|
+
this.saveFileCache();
|
|
308
|
+
return data;
|
|
309
|
+
|
|
310
|
+
} catch (error) {
|
|
311
|
+
console.error(`[Stock] ${symbol}: ${error.message}`);
|
|
312
|
+
|
|
313
|
+
if (this.cache.has(cacheKey)) {
|
|
314
|
+
return { ...this.cache.get(cacheKey), error: true, fromCache: true };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return {
|
|
318
|
+
symbol,
|
|
319
|
+
type: 'stock',
|
|
320
|
+
price: 0,
|
|
321
|
+
change: 0,
|
|
322
|
+
change24h: 0,
|
|
323
|
+
history: [0],
|
|
324
|
+
open: 0,
|
|
325
|
+
previousClose: 0,
|
|
326
|
+
high: 0,
|
|
327
|
+
low: 0,
|
|
328
|
+
high52w: 0,
|
|
329
|
+
low52w: 0,
|
|
330
|
+
marketCap: 0,
|
|
331
|
+
volume: 0,
|
|
332
|
+
avgVolume: 0,
|
|
333
|
+
pe: 0,
|
|
334
|
+
error: true
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async fetchAllAssets(tickers, cryptoIds, days = 7) {
|
|
340
|
+
const results = [];
|
|
341
|
+
|
|
342
|
+
// Sequential fetching to respect rate limits
|
|
343
|
+
for (const ticker of tickers) {
|
|
344
|
+
const coinId = cryptoIds[ticker];
|
|
345
|
+
if (coinId) {
|
|
346
|
+
results.push(await this.fetchCryptoData(ticker, coinId, days));
|
|
347
|
+
} else {
|
|
348
|
+
results.push(await this.fetchStockData(ticker, days));
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return results;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|