config/bin/stonks

476 lines
17 KiB
JavaScript
Executable file

#!/usr/bin/env node
// Finnhub stock market CLI tool
// Usage: stonks quote <symbol> — current price
// stonks search <query> — search for symbols
// stonks candles <symbol> [days] — price history (default: 30 days)
// stonks news <symbol> [days] — company news (default: 7 days)
// stonks profile <symbol> — company profile
// stonks portfolio — show virtual portfolio
// stonks buy <symbol> <amount> — virtual buy (amount in EUR)
// stonks sell <symbol> [amount] — virtual sell (amount in EUR, default: all)
// stonks limit-buy <symbol> <price> <amount> — set limit buy
// stonks limit-sell <symbol> <price> [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 <symbol> Current price');
console.log(' stonks search <query> Search symbols');
console.log(' stonks candles <symbol> [days] Price history');
console.log(' stonks news <symbol> [days] Company news');
console.log(' stonks profile <symbol> Company info');
console.log(' stonks portfolio Show portfolio');
console.log(' stonks buy <symbol> <amount> Buy (EUR amount)');
console.log(' stonks sell <symbol> [amount] Sell (EUR amount or all)');
console.log(' stonks limit-buy <sym> <price> <amt> Limit buy');
console.log(' stonks limit-sell <sym> <price> [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);
}
})();