#!/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 finnhub(endpoint) { return new Promise((resolve, reject) => { const url = `https://finnhub.io/api/v1${endpoint}&token=${API_KEY}`; https.get(url, (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); }); } async function quote(symbol) { const q = await finnhub(`/quote?symbol=${encodeURIComponent(symbol)}`); if (!q || q.c === 0) { console.error(`No data for ${symbol}. Try search to find the right symbol.`); return null; } 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; } 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 { const q = await finnhub(`/quote?symbol=${encodeURIComponent(pos.symbol)}`); const currentPrice = q.c || 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); } })();