init commit
This commit is contained in:
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"name": "bond-sniffer",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import http from 'http';
|
||||||
|
import https from 'https';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const PORT = 3000;
|
||||||
|
|
||||||
|
const MIME = {
|
||||||
|
'.html': 'text/html',
|
||||||
|
'.js': 'application/javascript',
|
||||||
|
'.css': 'text/css',
|
||||||
|
'.json': 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
|
function proxyOnvista(req, res) {
|
||||||
|
const url = new URL(req.url, `http://localhost:${PORT}`);
|
||||||
|
|
||||||
|
// Forward all query params as-is to onvista
|
||||||
|
const upstream = `https://api.onvista.de/api/v1/bonds/finder/configuration_query?${url.searchParams.toString()}`;
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
https.get(upstream, options, upRes => {
|
||||||
|
let body = '';
|
||||||
|
upRes.on('data', chunk => body += chunk);
|
||||||
|
upRes.on('end', () => {
|
||||||
|
res.writeHead(upRes.statusCode, {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
});
|
||||||
|
res.end(body);
|
||||||
|
});
|
||||||
|
}).on('error', err => {
|
||||||
|
res.writeHead(502, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: err.message }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function serveStatic(req, res) {
|
||||||
|
const filePath = path.join(__dirname, 'static', req.url === '/' ? 'index.html' : req.url);
|
||||||
|
const ext = path.extname(filePath);
|
||||||
|
|
||||||
|
fs.readFile(filePath, (err, data) => {
|
||||||
|
if (err) { res.writeHead(404); res.end('Not found'); return; }
|
||||||
|
res.writeHead(200, { 'Content-Type': MIME[ext] || 'text/plain' });
|
||||||
|
res.end(data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
if (req.url.startsWith('/api/bonds')) {
|
||||||
|
proxyOnvista(req, res);
|
||||||
|
} else {
|
||||||
|
serveStatic(req, res);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(PORT, () => console.log(`Bond Sniffer running at http://localhost:${PORT}`));
|
||||||
@@ -0,0 +1,357 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Bond Sniffer</title>
|
||||||
|
<style>
|
||||||
|
*, *::before, *::after { box-sizing: border-box; }
|
||||||
|
body { font-family: monospace; max-width: 1400px; margin: 40px auto; padding: 0 20px; background: #0f0f0f; color: #e0e0e0; }
|
||||||
|
h1 { color: #7dd3a8; margin-bottom: 2px; }
|
||||||
|
p.sub { color: #666; margin-top: 0; font-size: 12px; }
|
||||||
|
.filters { display: flex; gap: 10px; flex-wrap: wrap; margin: 16px 0 10px; align-items: flex-end; }
|
||||||
|
.filter-group { display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
label { font-size: 11px; color: #888; }
|
||||||
|
input, select, button {
|
||||||
|
background: #1a1a1a; color: #e0e0e0; border: 1px solid #333;
|
||||||
|
padding: 7px 10px; font-family: monospace; font-size: 12px; border-radius: 4px;
|
||||||
|
}
|
||||||
|
input[type="date"] { color-scheme: dark; }
|
||||||
|
select { min-width: 140px; }
|
||||||
|
button { background: #1e4d35; border-color: #7dd3a8; color: #7dd3a8; cursor: pointer; padding: 7px 16px; }
|
||||||
|
button:hover { background: #265c42; }
|
||||||
|
button:disabled { opacity: 0.4; cursor: default; }
|
||||||
|
#status { font-size: 12px; color: #888; margin: 6px 0; min-height: 18px; }
|
||||||
|
#status.error { color: #f87171; }
|
||||||
|
#status.ok { color: #7dd3a8; }
|
||||||
|
.pagination { display: flex; gap: 8px; align-items: center; margin: 10px 0; font-size: 12px; color: #888; }
|
||||||
|
.pagination button { padding: 4px 12px; font-size: 12px; }
|
||||||
|
table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||||
|
th { text-align: left; padding: 8px 10px; border-bottom: 1px solid #2a2a2a; color: #7dd3a8; cursor: pointer; user-select: none; white-space: nowrap; }
|
||||||
|
th:hover { color: #a7f3d0; }
|
||||||
|
th:hover { color: #a7f3d0; }
|
||||||
|
th.sorted-asc::after { content: ' ↑'; }
|
||||||
|
th.sorted-desc::after { content: ' ↓'; }
|
||||||
|
td { padding: 6px 10px; border-bottom: 1px solid #181818; white-space: nowrap; }
|
||||||
|
tr:hover td { background: #141414; }
|
||||||
|
.pill {
|
||||||
|
display: inline-block; padding: 1px 6px; border-radius: 3px;
|
||||||
|
font-size: 10px; background: #1a2e25; color: #7dd3a8;
|
||||||
|
}
|
||||||
|
.pill.red { background: #2e1a1a; color: #f87171; }
|
||||||
|
.pill.gray { background: #222; color: #888; }
|
||||||
|
a { color: #7dd3a8; text-decoration: none; }
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
.num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||||
|
.right { text-align: right; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<h1>Bond Sniffer</h1>
|
||||||
|
<p class="sub">Source: onvista.de — no API key required</p>
|
||||||
|
|
||||||
|
<div class="filters">
|
||||||
|
<div class="filter-group">
|
||||||
|
<label>Country</label>
|
||||||
|
<select id="fCountry">
|
||||||
|
<option value="">All</option>
|
||||||
|
<option value="DE" selected>Germany (DE)</option>
|
||||||
|
<option value="AT">Austria (AT)</option>
|
||||||
|
<option value="FR">France (FR)</option>
|
||||||
|
<option value="IT">Italy (IT)</option>
|
||||||
|
<option value="ES">Spain (ES)</option>
|
||||||
|
<option value="US">USA (US)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="filter-group">
|
||||||
|
<label>Currency</label>
|
||||||
|
<select id="fCurrency">
|
||||||
|
<option value="">All</option>
|
||||||
|
<option value="EUR" selected>EUR</option>
|
||||||
|
<option value="USD">USD</option>
|
||||||
|
<option value="GBP">GBP</option>
|
||||||
|
<option value="CHF">CHF</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="filter-group">
|
||||||
|
<label>Issuer type</label>
|
||||||
|
<select id="fIssuerType">
|
||||||
|
<option value="">All</option>
|
||||||
|
<option value="1" selected>Public (öffentlich)</option>
|
||||||
|
<option value="2">Financial institutions</option>
|
||||||
|
<option value="3">Supranational</option>
|
||||||
|
<option value="5">Corporate</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="filter-group">
|
||||||
|
<label>Maturity from</label>
|
||||||
|
<input type="date" id="fMaturityFrom" value="2025-01-01" />
|
||||||
|
</div>
|
||||||
|
<div class="filter-group">
|
||||||
|
<label>Maturity to</label>
|
||||||
|
<input type="date" id="fMaturityTo" />
|
||||||
|
</div>
|
||||||
|
<div class="filter-group">
|
||||||
|
<label>Sort by</label>
|
||||||
|
<select id="fSort">
|
||||||
|
<option value="coupon">Coupon</option>
|
||||||
|
<option value="yieldToMaturity">Yield to maturity</option>
|
||||||
|
<option value="datetimeMaturity">Maturity</option>
|
||||||
|
<option value="name">Name</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="filter-group">
|
||||||
|
<label>Order</label>
|
||||||
|
<select id="fOrder">
|
||||||
|
<option value="DESC">Desc</option>
|
||||||
|
<option value="ASC">Asc</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="filter-group">
|
||||||
|
<label>Coupon % min / max</label>
|
||||||
|
<div style="display:flex;gap:4px">
|
||||||
|
<input type="number" id="fCouponMin" placeholder="0" step="0.1" style="width:70px" oninput="rerender()" />
|
||||||
|
<input type="number" id="fCouponMax" placeholder="∞" step="0.1" style="width:70px" oninput="rerender()" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="filter-group">
|
||||||
|
<label>YTM % min / max</label>
|
||||||
|
<div style="display:flex;gap:4px">
|
||||||
|
<input type="number" id="fYtmMin" placeholder="0" step="0.1" style="width:70px" oninput="rerender()" />
|
||||||
|
<input type="number" id="fYtmMax" placeholder="∞" step="0.1" style="width:70px" oninput="rerender()" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="filter-group">
|
||||||
|
<label>Min volume 4w</label>
|
||||||
|
<input type="number" id="fMinVolume" value="0" min="0" step="1" style="width:90px" oninput="rerender()" />
|
||||||
|
</div>
|
||||||
|
<div class="filter-group">
|
||||||
|
<label> </label>
|
||||||
|
<button id="fetchBtn" onclick="fetchPage(0)">Search</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="status"></div>
|
||||||
|
|
||||||
|
<div class="pagination" id="paginationTop" style="display:none">
|
||||||
|
<button onclick="fetchPage(currentPage - 1)" id="prevBtn">← Prev</button>
|
||||||
|
<span id="pageInfo"></span>
|
||||||
|
<button onclick="fetchPage(currentPage + 1)" id="nextBtn">Next →</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table id="resultsTable" style="display:none">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th onclick="clientSort('name')">Name</th>
|
||||||
|
<th onclick="clientSort('isin')">ISIN</th>
|
||||||
|
<th onclick="clientSort('issuer')">Issuer</th>
|
||||||
|
<th onclick="clientSort('type')">Type</th>
|
||||||
|
<th class="right" onclick="clientSort('coupon')">Coupon %</th>
|
||||||
|
<th class="right" onclick="clientSort('ytm')">YTM %</th>
|
||||||
|
<th class="right" onclick="clientSort('price')">Price</th>
|
||||||
|
<th class="right" onclick="clientSort('volume')">Vol 4w</th>
|
||||||
|
<th onclick="clientSort('maturity')">Maturity</th>
|
||||||
|
<th onclick="clientSort('currency')">Currency</th>
|
||||||
|
<th onclick="clientSort('callable')">Callable</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="tableBody"></tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const PER_PAGE = 50;
|
||||||
|
let currentPage = 0;
|
||||||
|
let totalResults = 0;
|
||||||
|
let lastList = [];
|
||||||
|
let sortKey = null;
|
||||||
|
let sortDir = 1; // 1 = asc, -1 = desc
|
||||||
|
|
||||||
|
const SORT_ACCESSORS = {
|
||||||
|
name: b => b.instrument?.name ?? '',
|
||||||
|
isin: b => b.instrument?.isin ?? '',
|
||||||
|
issuer: b => b.bondsIssuer?.name ?? '',
|
||||||
|
type: b => b.bondsIssuer?.nameTypeIssuer ?? '',
|
||||||
|
coupon: b => b.bondsDetails?.coupon ?? -Infinity,
|
||||||
|
ytm: b => b.bondsFigures?.yieldToMaturity ?? -Infinity,
|
||||||
|
price: b => b.quote?.last ?? -Infinity,
|
||||||
|
volume: b => b.quote?.volume4Weeks ?? -Infinity,
|
||||||
|
maturity: b => b.bondsBaseData?.datetimeMaturity ?? '',
|
||||||
|
currency: b => b.bondsDetails?.isoCurrency ?? '',
|
||||||
|
callable: b => b.bondsBaseData?.callable ? 1 : 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
function clientSort(key) {
|
||||||
|
if (sortKey === key) sortDir *= -1;
|
||||||
|
else { sortKey = key; sortDir = 1; }
|
||||||
|
updateSortHeaders();
|
||||||
|
rerender();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSortHeaders() {
|
||||||
|
document.querySelectorAll('th').forEach(th => {
|
||||||
|
th.classList.remove('sorted-asc', 'sorted-desc');
|
||||||
|
if (th.getAttribute('onclick') === `clientSort('${sortKey}')`) {
|
||||||
|
th.classList.add(sortDir === 1 ? 'sorted-asc' : 'sorted-desc');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(msg, type = '') {
|
||||||
|
const el = document.getElementById('status');
|
||||||
|
el.textContent = msg;
|
||||||
|
el.className = type;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildQueryParameters() {
|
||||||
|
const parts = [];
|
||||||
|
const matFrom = document.getElementById('fMaturityFrom').value;
|
||||||
|
const matTo = document.getElementById('fMaturityTo').value;
|
||||||
|
if (matFrom || matTo) parts.push(`datetimeMaturityRange=${matFrom || ''};${matTo || ''}`);
|
||||||
|
const country = document.getElementById('fCountry').value;
|
||||||
|
if (country) parts.push(`isoCountry=${country}`);
|
||||||
|
const currency = document.getElementById('fCurrency').value;
|
||||||
|
if (currency) parts.push(`isoCurrency=${currency}`);
|
||||||
|
const issuerType = document.getElementById('fIssuerType').value;
|
||||||
|
if (issuerType) parts.push(`idTypeIssuer=${issuerType}`);
|
||||||
|
return parts.join('&');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchPage(page) {
|
||||||
|
if (page < 0) return;
|
||||||
|
currentPage = page;
|
||||||
|
|
||||||
|
const btn = document.getElementById('fetchBtn');
|
||||||
|
btn.disabled = true;
|
||||||
|
setStatus('Fetching…');
|
||||||
|
|
||||||
|
const sort = document.getElementById('fSort').value;
|
||||||
|
const order = document.getElementById('fOrder').value;
|
||||||
|
const qp = buildQueryParameters();
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
application: 'WEBSITE',
|
||||||
|
device: 'DESKTOP',
|
||||||
|
order,
|
||||||
|
page,
|
||||||
|
perPage: PER_PAGE,
|
||||||
|
sort,
|
||||||
|
queryParameters: qp,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/bonds?${params}`);
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (data.error) throw new Error(data.error);
|
||||||
|
|
||||||
|
totalResults = data.total ?? 0;
|
||||||
|
lastList = data.list ?? [];
|
||||||
|
renderTable(lastList);
|
||||||
|
renderPagination();
|
||||||
|
setStatus(`${totalResults} bonds found — page ${page + 1} of ${Math.ceil(totalResults / PER_PAGE)}`, 'ok');
|
||||||
|
} catch (e) {
|
||||||
|
setStatus(`Error: ${e.message}`, 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmt(val, decimals = 2) {
|
||||||
|
if (val == null) return '—';
|
||||||
|
return Number(val).toFixed(decimals);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDate(iso) {
|
||||||
|
if (!iso) return '—';
|
||||||
|
return iso.slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rerender() {
|
||||||
|
renderTable(lastList);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTable(list) {
|
||||||
|
if (sortKey) {
|
||||||
|
const accessor = SORT_ACCESSORS[sortKey];
|
||||||
|
list = list.slice().sort((a, b) => {
|
||||||
|
const va = accessor(a), vb = accessor(b);
|
||||||
|
if (va < vb) return -sortDir;
|
||||||
|
if (va > vb) return sortDir;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const couponMin = document.getElementById('fCouponMin').value !== '' ? parseFloat(document.getElementById('fCouponMin').value) : null;
|
||||||
|
const couponMax = document.getElementById('fCouponMax').value !== '' ? parseFloat(document.getElementById('fCouponMax').value) : null;
|
||||||
|
const ytmMin = document.getElementById('fYtmMin').value !== '' ? parseFloat(document.getElementById('fYtmMin').value) : null;
|
||||||
|
const ytmMax = document.getElementById('fYtmMax').value !== '' ? parseFloat(document.getElementById('fYtmMax').value) : null;
|
||||||
|
const minVolumeInput = document.getElementById('fMinVolume').value;
|
||||||
|
const minVolume = minVolumeInput !== '' ? parseFloat(minVolumeInput) : null;
|
||||||
|
|
||||||
|
const before = list.length;
|
||||||
|
list = list.filter(b => {
|
||||||
|
const coupon = b.bondsDetails?.coupon ?? null;
|
||||||
|
const ytm = b.bondsFigures?.yieldToMaturity ?? null;
|
||||||
|
const vol = b.quote?.volume4Weeks ?? 0;
|
||||||
|
if (couponMin !== null && (coupon === null || coupon < couponMin)) return false;
|
||||||
|
if (couponMax !== null && (coupon === null || coupon > couponMax)) return false;
|
||||||
|
if (ytmMin !== null && (ytm === null || ytm < ytmMin)) return false;
|
||||||
|
if (ytmMax !== null && (ytm === null || ytm > ytmMax)) return false;
|
||||||
|
if (minVolume !== null && vol <= minVolume) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
const hidden = before - list.length;
|
||||||
|
if (hidden > 0) {
|
||||||
|
const el = document.getElementById('status');
|
||||||
|
el.textContent += ` — ${hidden} filtered out`;
|
||||||
|
}
|
||||||
|
const tbody = document.getElementById('tableBody');
|
||||||
|
tbody.innerHTML = list.map(b => {
|
||||||
|
const inst = b.instrument ?? {};
|
||||||
|
const issuer = b.bondsIssuer ?? {};
|
||||||
|
const details = b.bondsDetails ?? {};
|
||||||
|
const base = b.bondsBaseData ?? {};
|
||||||
|
const figs = b.bondsFigures ?? {};
|
||||||
|
const quote = b.quote ?? {};
|
||||||
|
|
||||||
|
const name = inst.name ?? '—';
|
||||||
|
const url = inst.urls?.WEBSITE ?? '#';
|
||||||
|
const isin = inst.isin ?? '—';
|
||||||
|
const ytm = figs.yieldToMaturity;
|
||||||
|
const ytmClass = ytm != null && ytm < 0 ? 'red' : '';
|
||||||
|
const vol = quote.volume4Weeks;
|
||||||
|
|
||||||
|
return `<tr>
|
||||||
|
<td><a href="${url}" target="_blank">${name}</a></td>
|
||||||
|
<td>${isin}</td>
|
||||||
|
<td>${issuer.name ?? '—'}</td>
|
||||||
|
<td><span class="pill gray">${issuer.nameTypeIssuer ?? '—'}</span></td>
|
||||||
|
<td class="num">${fmt(details.coupon)}</td>
|
||||||
|
<td class="num"><span class="pill ${ytmClass}">${fmt(ytm, 3)}</span></td>
|
||||||
|
<td class="num">${fmt(quote.last)}</td>
|
||||||
|
<td class="num">${vol != null ? vol.toLocaleString('de-DE', {maximumFractionDigits: 0}) : '—'}</td>
|
||||||
|
<td>${fmtDate(base.datetimeMaturity)}</td>
|
||||||
|
<td><span class="pill">${details.isoCurrency ?? '—'}</span></td>
|
||||||
|
<td>${base.callable ? '<span class="pill red">yes</span>' : '<span class="pill gray">no</span>'}</td>
|
||||||
|
</tr>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
document.getElementById('resultsTable').style.display = list.length ? 'table' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPagination() {
|
||||||
|
const totalPages = Math.ceil(totalResults / PER_PAGE);
|
||||||
|
const pag = document.getElementById('paginationTop');
|
||||||
|
pag.style.display = totalPages > 1 ? 'flex' : 'none';
|
||||||
|
document.getElementById('pageInfo').textContent = `Page ${currentPage + 1} / ${totalPages} (${totalResults} total)`;
|
||||||
|
document.getElementById('prevBtn').disabled = currentPage === 0;
|
||||||
|
document.getElementById('nextBtn').disabled = currentPage >= totalPages - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-fetch on load
|
||||||
|
window.onload = () => fetchPage(0);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user