#!/usr/bin/env node // Finnhub stock market CLI tool // Usage: stonks quote — current price // stonks search — search for symbols // stonks candles [days] — price history (default: 30 days) // stonks news [days] — company news (default: 7 days) // stonks profile — company profile // stonks portfolio — show virtual portfolio // stonks buy — virtual buy (amount in EUR) // stonks sell [amount] — virtual sell (amount in EUR, default: all) // stonks limit-buy — set limit buy // stonks limit-sell [amount] — set limit sell // stonks check-limits — check & execute triggered limit orders // stonks history — order history const https = require('https'); const fs = require('fs'); const path = require('path'); // Load API key from global services.env const envPath = path.join(__dirname, '..', '.credentials', 'services.env'); let API_KEY = ''; try { const env = fs.readFileSync(envPath, 'utf8'); const match = env.match(/FINNHUB_API_KEY=(.+)/); if (match) API_KEY = match[1].trim(); } catch (e) {} if (!API_KEY || API_KEY === 'FILL_IN') { console.error('ERROR: Set FINNHUB_API_KEY in .credentials/finnhub.env'); process.exit(1); } const PORTFOLIO_PATH = path.join(__dirname, '..', 'memory', 'portfolio.json'); function loadPortfolio() { try { return JSON.parse(fs.readFileSync(PORTFOLIO_PATH, 'utf8')); } catch (e) { const initial = { startingCapital: 1000, cash: 1000, currency: 'EUR', positions: [], limitOrders: [], history: [], created: new Date().toISOString() }; fs.writeFileSync(PORTFOLIO_PATH, JSON.stringify(initial, null, 2)); return initial; } } function savePortfolio(p) { fs.writeFileSync(PORTFOLIO_PATH, JSON.stringify(p, null, 2)); } function httpGet(url) { const mod = url.startsWith('https') ? https : require('http'); return new Promise((resolve, reject) => { mod.get(url, { headers: { 'User-Agent': 'Mozilla/5.0' } }, (res) => { let data = ''; res.on('data', (c) => data += c); res.on('end', () => { try { resolve(JSON.parse(data)); } catch (e) { reject(new Error(`Parse error: ${data.slice(0, 200)}`)); } }); }).on('error', reject); }); } function finnhub(endpoint) { return httpGet(`https://finnhub.io/api/v1${endpoint}&token=${API_KEY}`); } // Onvista fallback for EU ETFs/stocks not covered by Finnhub async function onvistaSearch(query) { const r = await httpGet(`https://api.onvista.de/api/v1/instruments/search?searchValue=${encodeURIComponent(query)}`); return r && r.list ? r.list : []; } async function onvistaQuote(entityValue) { const r = await httpGet(`https://api.onvista.de/api/v1/instruments/FUND/${entityValue}/snapshot`); if (r && r.quote) return r.quote; // Try STOCK type const r2 = await httpGet(`https://api.onvista.de/api/v1/instruments/STOCK/${entityValue}/snapshot`); return r2 && r2.quote ? r2.quote : null; } // ISIN-to-onvista entity cache const ONVISTA_CACHE_PATH = path.join(__dirname, '..', 'memory', 'onvista-cache.json'); function loadOnvistaCache() { try { return JSON.parse(fs.readFileSync(ONVISTA_CACHE_PATH, 'utf8')); } catch { return {}; } } function saveOnvistaCache(c) { fs.writeFileSync(ONVISTA_CACHE_PATH, JSON.stringify(c, null, 2)); } async function onvistaQuoteByISIN(isin, name) { const cache = loadOnvistaCache(); let entityValue = cache[isin]; if (!entityValue) { const results = await onvistaSearch(name || isin); const match = results.find(r => r.isin === isin); if (!match) return null; entityValue = match.entityValue; cache[isin] = entityValue; saveOnvistaCache(cache); } return onvistaQuote(entityValue); } async function quote(symbol) { // First check if we have this in portfolio with ISIN — prefer onvista for EU instruments const p = loadPortfolio(); const pos = p.positions.find(pos => pos.symbol === symbol || pos.symbol + '.DE' === symbol); if (pos && pos.isin) { try { const oq = await onvistaQuoteByISIN(pos.isin, pos.name); if (oq && oq.last) { const change = oq.changeAbsolute || 0; const changePct = oq.changePrevDay || 0; console.log(`${symbol}\t${oq.last}\t${change >= 0 ? '+' : ''}${change.toFixed(2)}\t${changePct >= 0 ? '+' : ''}${changePct.toFixed(2)}%\tH:${oq.high || 'N/A'}\tL:${oq.low || 'N/A'}\tO:${oq.open || 'N/A'}\tPC:${oq.previousLast || 'N/A'}\t(onvista)`); return { c: oq.last, d: change, dp: changePct, h: oq.high, l: oq.low, o: oq.open, pc: oq.previousLast }; } } catch (e) {} } // Try Finnhub const q = await finnhub(`/quote?symbol=${encodeURIComponent(symbol)}`); if (q && q.c && q.c !== 0) { const change = q.d || 0; const changePct = q.dp || 0; console.log(`${symbol}\t${q.c}\t${change >= 0 ? '+' : ''}${change}\t${changePct >= 0 ? '+' : ''}${changePct}%\tH:${q.h}\tL:${q.l}\tO:${q.o}\tPC:${q.pc}`); return q; } // Last resort: check portfolio for ISIN without match above if (pos && pos.isin) { try { const oq = await onvistaQuoteByISIN(pos.isin, pos.name); if (oq && oq.last) { const change = oq.changeAbsolute || 0; const changePct = oq.changePrevDay || 0; console.log(`${symbol}\t${oq.last}\t${change >= 0 ? '+' : ''}${change.toFixed(2)}\t${changePct >= 0 ? '+' : ''}${changePct.toFixed(2)}%\tH:${oq.high || 'N/A'}\tL:${oq.low || 'N/A'}\tO:${oq.open || 'N/A'}\tPC:${oq.previousLast || 'N/A'}\t(onvista)`); return { c: oq.last, d: change, dp: changePct, h: oq.high, l: oq.low, o: oq.open, pc: oq.previousLast }; } } catch (e) {} } console.error(`No data for ${symbol}. Try search to find the right symbol.`); return null; } async function search(query) { const r = await finnhub(`/search?q=${encodeURIComponent(query)}`); if (!r || !r.result || r.result.length === 0) { console.log('No results found.'); return; } // Show top 15 results r.result.slice(0, 15).forEach(s => { console.log(`${s.symbol}\t${s.description}\t${s.type}`); }); } async function candles(symbol, days = 30) { const to = Math.floor(Date.now() / 1000); const from = to - (days * 86400); const r = await finnhub(`/stock/candle?symbol=${encodeURIComponent(symbol)}&resolution=D&from=${from}&to=${to}`); if (!r || r.s === 'no_data') { console.log(`No candle data for ${symbol}.`); return; } console.log('Date\tOpen\tHigh\tLow\tClose\tVolume'); for (let i = 0; i < r.t.length; i++) { const d = new Date(r.t[i] * 1000).toISOString().split('T')[0]; console.log(`${d}\t${r.o[i]}\t${r.h[i]}\t${r.l[i]}\t${r.c[i]}\t${r.v[i]}`); } } async function news(symbol, days = 7) { const to = new Date().toISOString().split('T')[0]; const from = new Date(Date.now() - days * 86400000).toISOString().split('T')[0]; const r = await finnhub(`/company-news?symbol=${encodeURIComponent(symbol)}&from=${from}&to=${to}`); if (!r || r.length === 0) { console.log('No news found.'); return; } r.slice(0, 10).forEach(n => { const d = new Date(n.datetime * 1000).toISOString().split('T')[0]; console.log(`${d}\t${n.headline}\t${n.url}`); }); } async function profile(symbol) { const r = await finnhub(`/stock/profile2?symbol=${encodeURIComponent(symbol)}`); if (!r || !r.name) { console.log(`No profile for ${symbol}.`); return; } console.log(`Name:\t${r.name}`); console.log(`Country:\t${r.country}`); console.log(`Exchange:\t${r.exchange}`); console.log(`Industry:\t${r.finnhubIndustry}`); console.log(`Market Cap:\t${r.marketCapitalization}M`); console.log(`IPO:\t${r.ipo}`); console.log(`URL:\t${r.weburl}`); } async function showPortfolio() { const p = loadPortfolio(); let totalValue = p.cash; console.log(`=== Hoid's Virtual Portfolio ===`); console.log(`Starting Capital:\t€${p.startingCapital.toFixed(2)}`); console.log(`Cash:\t€${p.cash.toFixed(2)}`); console.log(''); if (p.positions.length > 0) { console.log('Symbol\tShares\tAvg Price\tCurrent\tValue\tP&L\tP&L%'); for (const pos of p.positions) { try { let currentPrice = null; // Try onvista first for positions with ISIN (EU instruments) if (pos.isin) { try { const oq = await onvistaQuoteByISIN(pos.isin, pos.name); if (oq && oq.last) currentPrice = oq.last; } catch (e) {} } // Fallback to Finnhub if (!currentPrice) { const q = await finnhub(`/quote?symbol=${encodeURIComponent(pos.symbol)}`); if (q && q.c && q.c > 0) currentPrice = q.c; } currentPrice = currentPrice || pos.avgPrice; const value = pos.shares * currentPrice; const cost = pos.shares * pos.avgPrice; const pnl = value - cost; const pnlPct = ((pnl / cost) * 100); totalValue += value; console.log(`${pos.symbol}\t${pos.shares.toFixed(4)}\t${pos.avgPrice.toFixed(2)}\t${currentPrice.toFixed(2)}\t€${value.toFixed(2)}\t${pnl >= 0 ? '+' : ''}€${pnl.toFixed(2)}\t${pnlPct >= 0 ? '+' : ''}${pnlPct.toFixed(1)}%`); } catch (e) { const value = pos.shares * pos.avgPrice; totalValue += value; console.log(`${pos.symbol}\t${pos.shares.toFixed(4)}\t${pos.avgPrice.toFixed(2)}\t???\t€${value.toFixed(2)}\t???\t???`); } } } else { console.log('No positions.'); } console.log(''); const totalPnl = totalValue - p.startingCapital; const totalPnlPct = ((totalPnl / p.startingCapital) * 100); console.log(`Total Value:\t€${totalValue.toFixed(2)}`); console.log(`Total P&L:\t${totalPnl >= 0 ? '+' : ''}€${totalPnl.toFixed(2)} (${totalPnlPct >= 0 ? '+' : ''}${totalPnlPct.toFixed(1)}%)`); if (p.limitOrders.length > 0) { console.log(''); console.log('=== Pending Limit Orders ==='); console.log('ID\tType\tSymbol\tLimit Price\tAmount\tCreated'); p.limitOrders.forEach(o => { console.log(`${o.id}\t${o.type}\t${o.symbol}\t${o.limitPrice}\t€${o.amount.toFixed(2)}\t${o.created.split('T')[0]}`); }); } } async function buy(symbol, amount) { const p = loadPortfolio(); amount = parseFloat(amount); if (amount > p.cash) { console.error(`Not enough cash. Have €${p.cash.toFixed(2)}, need €${amount.toFixed(2)}`); return; } const q = await finnhub(`/quote?symbol=${encodeURIComponent(symbol)}`); if (!q || q.c === 0) { console.error(`Cannot get price for ${symbol}`); return; } const price = q.c; const shares = amount / price; // Update or create position const existing = p.positions.find(pos => pos.symbol === symbol); if (existing) { const totalCost = (existing.shares * existing.avgPrice) + amount; existing.shares += shares; existing.avgPrice = totalCost / existing.shares; } else { p.positions.push({ symbol, shares, avgPrice: price }); } p.cash -= amount; p.history.push({ type: 'BUY', symbol, shares: shares, price, amount, date: new Date().toISOString() }); savePortfolio(p); console.log(`BUY\t${symbol}\t${shares.toFixed(4)} shares @ ${price}\t€${amount.toFixed(2)}`); console.log(`Cash remaining:\t€${p.cash.toFixed(2)}`); } async function sell(symbol, amount) { const p = loadPortfolio(); const pos = p.positions.find(pos => pos.symbol === symbol); if (!pos) { console.error(`No position in ${symbol}`); return; } const q = await finnhub(`/quote?symbol=${encodeURIComponent(symbol)}`); if (!q || q.c === 0) { console.error(`Cannot get price for ${symbol}`); return; } const price = q.c; let sharesToSell; if (!amount || amount === 'all') { sharesToSell = pos.shares; } else { sharesToSell = parseFloat(amount) / price; if (sharesToSell > pos.shares) sharesToSell = pos.shares; } const proceeds = sharesToSell * price; const cost = sharesToSell * pos.avgPrice; const pnl = proceeds - cost; pos.shares -= sharesToSell; if (pos.shares < 0.0001) { p.positions = p.positions.filter(pp => pp.symbol !== symbol); } p.cash += proceeds; p.history.push({ type: 'SELL', symbol, shares: sharesToSell, price, amount: proceeds, pnl, date: new Date().toISOString() }); savePortfolio(p); console.log(`SELL\t${symbol}\t${sharesToSell.toFixed(4)} shares @ ${price}\t€${proceeds.toFixed(2)}\tP&L: ${pnl >= 0 ? '+' : ''}€${pnl.toFixed(2)}`); console.log(`Cash:\t€${p.cash.toFixed(2)}`); } function limitOrder(type, symbol, limitPrice, amount) { const p = loadPortfolio(); limitPrice = parseFloat(limitPrice); amount = amount ? parseFloat(amount) : null; if (type === 'limit-buy' && amount > p.cash) { console.error(`Not enough cash. Have €${p.cash.toFixed(2)}`); return; } const id = Math.random().toString(36).slice(2, 8); p.limitOrders.push({ id, type: type === 'limit-buy' ? 'BUY' : 'SELL', symbol, limitPrice, amount: amount || 0, created: new Date().toISOString() }); savePortfolio(p); console.log(`Limit ${type === 'limit-buy' ? 'BUY' : 'SELL'}\t${symbol}\t@ ${limitPrice}\t€${(amount || 0).toFixed(2)}\tID: ${id}`); } async function checkLimits() { const p = loadPortfolio(); if (p.limitOrders.length === 0) { console.log('No pending limit orders.'); return; } const triggered = []; const remaining = []; for (const order of p.limitOrders) { try { const q = await finnhub(`/quote?symbol=${encodeURIComponent(order.symbol)}`); const price = q.c; if (order.type === 'BUY' && price <= order.limitPrice) { triggered.push({ ...order, currentPrice: price }); } else if (order.type === 'SELL' && price >= order.limitPrice) { triggered.push({ ...order, currentPrice: price }); } else { remaining.push(order); console.log(`PENDING\t${order.type}\t${order.symbol}\tLimit: ${order.limitPrice}\tCurrent: ${price}`); } } catch (e) { remaining.push(order); } } p.limitOrders = remaining; savePortfolio(p); for (const order of triggered) { console.log(`TRIGGERED\t${order.type}\t${order.symbol}\t@ ${order.currentPrice} (limit: ${order.limitPrice})`); if (order.type === 'BUY') { await buy(order.symbol, order.amount); } else { await sell(order.symbol, order.amount || 'all'); } } } function showHistory() { const p = loadPortfolio(); if (p.history.length === 0) { console.log('No trades yet.'); return; } console.log('Date\tType\tSymbol\tShares\tPrice\tAmount\tP&L'); p.history.forEach(h => { const pnl = h.pnl ? `${h.pnl >= 0 ? '+' : ''}€${h.pnl.toFixed(2)}` : ''; console.log(`${h.date.split('T')[0]}\t${h.type}\t${h.symbol}\t${h.shares.toFixed(4)}\t${h.price}\t€${h.amount.toFixed(2)}\t${pnl}`); }); } // Main const [,, cmd, ...args] = process.argv; (async () => { try { switch (cmd) { case 'quote': await quote(args[0]); break; case 'search': await search(args.join(' ')); break; case 'candles': await candles(args[0], args[1] || 30); break; case 'news': await news(args[0], args[1] || 7); break; case 'profile': await profile(args[0]); break; case 'portfolio': await showPortfolio(); break; case 'buy': await buy(args[0], args[1]); break; case 'sell': await sell(args[0], args[1]); break; case 'limit-buy': limitOrder('limit-buy', args[0], args[1], args[2]); break; case 'limit-sell': limitOrder('limit-sell', args[0], args[1], args[2]); break; case 'check-limits': await checkLimits(); break; case 'history': showHistory(); break; default: console.log('Usage:'); console.log(' stonks quote Current price'); console.log(' stonks search Search symbols'); console.log(' stonks candles [days] Price history'); console.log(' stonks news [days] Company news'); console.log(' stonks profile Company info'); console.log(' stonks portfolio Show portfolio'); console.log(' stonks buy Buy (EUR amount)'); console.log(' stonks sell [amount] Sell (EUR amount or all)'); console.log(' stonks limit-buy Limit buy'); console.log(' stonks limit-sell [amt] Limit sell'); console.log(' stonks check-limits Check triggered limits'); console.log(' stonks history Trade history'); } } catch (e) { console.error(`Error: ${e.message}`); process.exit(1); } })();