From 9630edc4d48925e913cab27bbb183c74a24ce3d9 Mon Sep 17 00:00:00 2001 From: Hoid Date: Thu, 12 Feb 2026 19:51:50 +0000 Subject: [PATCH] Add stonks CLI (Finnhub), portfolio tracker, morning briefing + weekly review crons --- TOOLS.md | 43 ++++ bin/stonks | 396 ++++++++++++++++++++++++++++++++++++ memory/bg3.json | 9 + memory/heartbeat-state.json | 10 +- memory/tasks.json | 26 ++- memory/wind-down-log.json | 21 +- 6 files changed, 484 insertions(+), 21 deletions(-) create mode 100755 bin/stonks create mode 100644 memory/bg3.json diff --git a/TOOLS.md b/TOOLS.md index ab86de4..584360d 100644 --- a/TOOLS.md +++ b/TOOLS.md @@ -220,4 +220,47 @@ tasks recurring --- +## Finnhub Stock Market API + +Helper script: `~/.openclaw/workspace/bin/stonks` + +```bash +stonks quote # Current price +stonks search # Search for symbols +stonks candles [days] # Price history (default: 30) +stonks news [days] # Company news (default: 7) +stonks profile # Company info +stonks portfolio # Show virtual portfolio +stonks buy # Virtual buy (EUR) +stonks sell [amount] # Virtual sell (EUR or all) +stonks limit-buy # Limit buy order +stonks limit-sell [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. diff --git a/bin/stonks b/bin/stonks new file mode 100755 index 0000000..c32e01a --- /dev/null +++ b/bin/stonks @@ -0,0 +1,396 @@ +#!/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 +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 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); + } +})(); diff --git a/memory/bg3.json b/memory/bg3.json new file mode 100644 index 0000000..8686490 --- /dev/null +++ b/memory/bg3.json @@ -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." +} diff --git a/memory/heartbeat-state.json b/memory/heartbeat-state.json index 00b598c..504e18f 100644 --- a/memory/heartbeat-state.json +++ b/memory/heartbeat-state.json @@ -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": { @@ -44,4 +44,4 @@ "status": "watching" } } -} +} \ No newline at end of file diff --git a/memory/tasks.json b/memory/tasks.json index 792739a..c95b9fe 100644 --- a/memory/tasks.json +++ b/memory/tasks.json @@ -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." } ] } diff --git a/memory/wind-down-log.json b/memory/wind-down-log.json index 1d648b6..e690052 100644 --- a/memory/wind-down-log.json +++ b/memory/wind-down-log.json @@ -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." }