Files
Lukas Cremer 9576e9cf44 init commit
2026-05-16 15:57:49 +02:00

358 lines
14 KiB
HTML

<!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>&nbsp;</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>