Add stonks CLI (Finnhub), portfolio tracker, morning briefing + weekly review crons

This commit is contained in:
Hoid 2026-02-12 19:51:50 +00:00
parent 956709f739
commit 9630edc4d4
6 changed files with 484 additions and 21 deletions

View file

@ -220,4 +220,47 @@ tasks recurring
---
## Finnhub Stock Market API
Helper script: `~/.openclaw/workspace/bin/stonks`
```bash
stonks quote <symbol> # Current price
stonks search <query> # Search for symbols
stonks candles <symbol> [days] # Price history (default: 30)
stonks news <symbol> [days] # Company news (default: 7)
stonks profile <symbol> # Company info
stonks portfolio # Show virtual portfolio
stonks buy <symbol> <amount> # Virtual buy (EUR)
stonks sell <symbol> [amount] # Virtual sell (EUR or all)
stonks limit-buy <sym> <price> <amt> # Limit buy order
stonks limit-sell <sym> <price> [amt] # Limit sell order
stonks check-limits # Check & execute triggered limits
stonks history # Trade history
```
- Credentials: `.credentials/finnhub.env`
- Portfolio data: `memory/portfolio.json`
- Virtual starting capital: €1,000
- **N26 constraint:** Only trade instruments available on N26 (check with user)
- Symbols: Use Finnhub format (e.g., `RHM.DE` for Xetra, `AAPL` for NASDAQ)
**Portfolio management:**
- Check limits during morning briefing and heartbeats
- Include portfolio status in morning briefing and weekly review
- Be proactive about market-moving news affecting positions
---
## Scheduled Briefings
| Briefing | Schedule | Content |
|----------|----------|---------|
| Morning Briefing | Daily 9:00 Vienna | Calendar, weather, tasks, portfolio, news |
| Weekly Review | Sunday 18:00 Vienna | Week recap, open tasks, portfolio performance, next week preview |
| News (Der Standard) | 10:00, 14:00, 18:00, 22:00 Vienna | Austrian/international news |
| News (AI) | 10:00, 14:00, 18:00, 22:00 Vienna | AI/tech news |
---
Add whatever helps you do your job. This is your cheat sheet.

396
bin/stonks Executable file
View file

@ -0,0 +1,396 @@
#!/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
const envPath = path.join(__dirname, '..', '.credentials', 'finnhub.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);
}
})();

9
memory/bg3.json Normal file
View file

@ -0,0 +1,9 @@
{
"party": [],
"level": 3,
"act": 1,
"currentQuest": "Rescue Halsin (goblin camp — too low level currently)",
"completedAreas": [],
"decisions": [],
"notes": "Suggested leveling in Emerald Grove, Blighted Village, Owlbear Cave, Risen Road before tackling goblin camp."
}

View file

@ -8,12 +8,12 @@
},
"lastChecks": {
"news": "2026-01-30T08:17:00Z",
"rheinmetall": "2026-02-07T08:41:00Z",
"rheinmetall_price": 1587.00,
"calendar": "2026-02-04T04:25:00Z",
"rheinmetall": "2026-02-10T08:11:00Z",
"rheinmetall_price": 1653.0,
"calendar": "2026-02-10T08:11:00Z",
"steam_hardware": "2026-02-03T22:00:00Z",
"hamr_40tb": "2026-02-04T21:10:00Z",
"notes": "RHM: €1,902.00, below €1,950 threshold. Steam hardware: still 'early 2026', no official price/date. HAMR 40TB: expected mid-2026, no release date yet."
"notes": "RHM: \u20ac1,653.00, well below \u20ac1,950. Calendar: nextcloud creds missing, could not check."
},
"watchlist": {
"hamr_40tb": {

View file

@ -18,19 +18,25 @@
"context": "Monorepo is done, using GitHub actions as fallback for now"
},
{
"id": "af198211",
"added": "2026-02-07",
"text": "Set up MCP server for Forgejo instance",
"priority": "soon",
"context": "Phase 1: MCP server for interactive Claude web sessions (fork + PR workflow). Phase 2: Automated issue-solving pipeline with clarification comments.",
"lastNudged": "2026-02-07T14:11:07.284Z"
"id": "c0bfb64b",
"added": "2026-02-10",
"text": "WoW bot with image recognition",
"priority": "someday",
"context": "Look into programming a bot for World of Warcraft using image recognition. Old idea worth revisiting."
},
{
"id": "0902aaba",
"added": "2026-02-07",
"text": "TYPO3 v13 upgrade for GBV",
"id": "29f86057",
"added": "2026-02-10",
"text": "Look into Showboat & Rodney (Simon Willison)",
"priority": "someday",
"context": "CLI tools for coding agents to prove their work. Showboat: agents build Markdown demos with real command outputs. Rodney: lightweight browser automation for agents to capture screenshots. Both designed for agent-first workflows. https://simonwillison.net"
},
{
"id": "07569224",
"added": "2026-02-11",
"text": "Herman Miller Embody kaufen (chairgo.de)",
"priority": "soon",
"lastNudged": "2026-02-07T14:11:07.284Z"
"context": "Ergonomischer Bürostuhl für Programmier-Setup. ~€1.800-2.000. Evtl. probesitzen in Wien vorher."
}
]
}

View file

@ -1,8 +1,17 @@
{
"date": "2026-02-07",
"entries": [
{"time": "19:14", "activity": "Tinkering — troubleshooting Bazzite suspend/resume + VRAM on GPD Win 4", "note": "Saturday evening, tech tinkering not deep work. Active conversation."},
{"time": "19:44", "activity": "Still GPD tinkering — fixed VRAM (8GB via UMAF), narrowed suspend crash to CPU hotplug (PowerTools core parking + SMT)", "note": "Making progress, having fun with it. Not work."},
{"time": "20:10", "activity": "Done tinkering — nosmt kernel param fixed suspend. Switching to YouTube then TV/audiobook", "note": "Good wind-down transition. Reminded about nose shower."}
]
"date": "2026-02-11",
"events": [
{ "time": "19:00", "activity": "Playing BG3 (wind-down gaming)" },
{ "time": "20:11", "note": "Still gaming, quiet" },
{ "time": "20:43", "activity": "Defeated the hag in BG3, nose shower done ✅" },
{ "time": "20:44", "activity": "Watching something, maybe more BG3 after" },
{ "time": "20:56", "activity": "Researching inflation/investment calculations" },
{ "time": "21:00", "activity": "Reading news briefing" },
{ "time": "23:01", "activity": "Still playing BG3 (Illithid powers decision)" },
{ "time": "23:18", "activity": "Still gaming, asking about party comp" },
{ "time": "23:50", "activity": "Still gaming (Wyll question)" },
{ "time": "00:04", "activity": "Stopped gaming. Drinking tea + audiobook (Askir). Winding down ✅" },
{ "time": "00:10", "activity": "Going to sleep 🌙" }
],
"summary": "Good wind-down. Gamed BG3 until ~midnight, then tea+audiobook. Nose shower done. Asleep around 00:10. Gaming ran a bit late but transitioned well."
}