Add onvista fallback for EU ETF/stock quotes in stonks script
This commit is contained in:
parent
f6f52ba1b9
commit
954ffebaad
3 changed files with 126 additions and 18 deletions
98
bin/stonks
98
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);
|
||||
}
|
||||
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 {
|
||||
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)}`);
|
||||
const currentPrice = q.c || pos.avgPrice;
|
||||
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;
|
||||
|
|
|
|||
3
memory/onvista-cache.json
Normal file
3
memory/onvista-cache.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"IE000YYE6WK5": "241121751"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue