From 954ffebaad0f7c95bf64bb58aea009ecaf74d217 Mon Sep 17 00:00:00 2001 From: Hoid Date: Fri, 13 Feb 2026 12:04:05 +0000 Subject: [PATCH] Add onvista fallback for EU ETF/stock quotes in stonks script --- bin/stonks | 108 +++++++++++++++++++++++++++++++++----- memory/onvista-cache.json | 3 ++ memory/portfolio.json | 33 ++++++++++-- 3 files changed, 126 insertions(+), 18 deletions(-) create mode 100644 memory/onvista-cache.json diff --git a/bin/stonks b/bin/stonks index bac4d22..9a455c4 100755 --- a/bin/stonks +++ b/bin/stonks @@ -56,10 +56,10 @@ function savePortfolio(p) { fs.writeFileSync(PORTFOLIO_PATH, JSON.stringify(p, null, 2)); } -function finnhub(endpoint) { +function httpGet(url) { + const mod = url.startsWith('https') ? https : require('http'); return new Promise((resolve, reject) => { - const url = `https://finnhub.io/api/v1${endpoint}&token=${API_KEY}`; - https.get(url, (res) => { + mod.get(url, { headers: { 'User-Agent': 'Mozilla/5.0' } }, (res) => { let data = ''; res.on('data', (c) => data += c); res.on('end', () => { @@ -70,16 +70,84 @@ function finnhub(endpoint) { }); } -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; +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); } - 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; + 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) { @@ -151,8 +219,20 @@ async function showPortfolio() { 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; + 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; diff --git a/memory/onvista-cache.json b/memory/onvista-cache.json new file mode 100644 index 0000000..d431dd1 --- /dev/null +++ b/memory/onvista-cache.json @@ -0,0 +1,3 @@ +{ + "IE000YYE6WK5": "241121751" +} \ No newline at end of file diff --git a/memory/portfolio.json b/memory/portfolio.json index 6c6cde6..aa4a223 100644 --- a/memory/portfolio.json +++ b/memory/portfolio.json @@ -1,10 +1,35 @@ { "startingCapital": 1000, - "cash": 1000, + "cash": 300, "currency": "EUR", - "positions": [], + "positions": [ + { + "symbol": "DFNS", + "name": "VanEck Defense UCITS ETF A", + "isin": "IE000YYE6WK5", + "buyDate": "2026-02-13", + "avgPrice": 55.93, + "shares": 12.516, + "amount": 700 + } + ], "limitOrders": [], - "history": [], + "watchlist": [ + { "symbol": "RHM.DE", "isin": "DE0007030009", "name": "Rheinmetall", "note": "Buy if major dip >10%" }, + { "symbol": "XDWD.DE", "isin": "IE00BJ0KDQ92", "name": "Xtrackers MSCI World", "note": "Safe diversification option" } + ], + "history": [ + { + "date": "2026-02-13", + "action": "BUY", + "symbol": "DFNS", + "isin": "IE000YYE6WK5", + "amount": 700, + "price": 55.93, + "reason": "European defense rearmament thesis. Diversified defense exposure via ETF. Strong momentum, geopolitical tailwinds." + } + ], "notes": "N26 uses Xetra tickers. Always provide ISIN for orders. Fractional shares by EUR amount supported.", - "created": "2026-02-12T20:00:00Z" + "created": "2026-02-12T20:00:00Z", + "lastUpdated": "2026-02-13T08:52:00Z" }