396 lines
13 KiB
JavaScript
Executable file
396 lines
13 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 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 <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);
|
|
}
|
|
})();
|