(function () { 'use strict'; // Excel: personal/investments/bonds/data/Bond.xlsx // Cache-bust so updated Excel always refreshes after re-upload var DATA_FILE = new URL('../data/Bond.xlsx', window.location.href).href + '?t=' + Date.now(); var LANG = (document.body && document.body.getAttribute('data-lang')) || 'en'; var I18N = { en: { all: 'All', loading: 'Loading…', emptyCategory: 'No bonds in this category.', emptyFilter: 'No bonds match the selected filters.', loadError: 'Unable to load bond data. Please try again later.', maturity: 'Maturity', coupon: 'Coupon', risk: 'Product Risk Level', gain: 'Gain', loss: 'Loss', columns: { isin: 'ISIN', name: 'Bond Name', currency: 'Currency', couponPct: 'Coupon (%)', freq: 'Coupon Frequency', riskLevel: 'Product Risk Level', years: 'Years to Maturity', }, scenarios: { hold: { title: '1) Hold to Maturity', badge: 'Gain', badgeClass: '', desc: 'You will receive coupon interest every half year until maturity and the bond face value on the maturity date.', lines: [ { label: 'Bond Face Value Upon Maturity', value: 'US$100,000' }, { label: 'Total Receivable at maturity', value: '(US$3,000 X 8) + US$100,000 = US$ 124,000' }, ], totalLabel: 'Total Return', totalValue: 'US$124,000 - US$103,000 = US$ 21,000', }, sell105: { title: '2) Sell @ 105%', badge: 'Gain', badgeClass: '', desc: 'You sell the bond at 30 Aug 2026 on 105% of the Bond Price', lines: [ { label: '105% of Bond Face Value', value: 'US$100,000 x 105% = US$105,000' }, { label: 'Total Receivable', value: 'US$3,000 + US$105,000 = US$ 108,000' }, ], totalLabel: 'Total Return', totalValue: 'US$108,000 - US$103,000 = US$ 5,000', }, sell95: { title: '3) Sell @ 95%', badge: 'Loss', badgeClass: 'bond-badge--loss', desc: 'You sell the bond at 31 Mar 2027 on 95% of the Bond Price', lines: [ { label: '95% of Bond Face Value', value: 'US$100,000 x 95% = US$95,000' }, { label: 'Total Receivable', value: '(US$3,000 X 2) + US$95,000 = US$ 101,000' }, ], totalLabel: 'Total Loss', totalValue: 'US$101,000 - US$103,000 = (US$ 2,000)', }, default: { title: '4) Issuer Default', desc: 'There is a risk that the issuer may fail to pay you the interest or principal as scheduled. In the worst-case scenario, you may not be able to receive back the interest and principal if the issuer defaults on the bond.', lines: [], totalLabel: '', totalValue: '', }, }, }, tc: { all: '全部', loading: '載入中…', emptyCategory: '此類別暫無債券。', emptyFilter: '沒有符合所選條件的債券。', loadError: '未能載入債券資料,請稍後再試。', maturity: '到期日', coupon: '票息', risk: '產品風險', gain: '回報', loss: '損失', columns: { isin: 'ISIN', name: '債券名稱', currency: '貨幣', couponPct: '票息 (%)', freq: '派息頻率', riskLevel: '產品風險', years: '餘下年期', }, scenarios: { hold: { title: '1) 持有直至到期日', badge: '回報', badgeClass: '', desc: '在債券到期前,你將會每半年收到一次利息,並於到期日當天取回債券面額。', lines: [ { label: '債券到期面額', value: 'US$100,000' }, { label: '到期時應收款項', value: '(US$3,000 X 8) + US$100,000 = US$ 124,000' }, ], totalLabel: '總投資回報', totalValue: 'US$124,000 - US$103,000 = US$ 21,000', }, sell105: { title: '2) 於105%出售', badge: '回報', badgeClass: '', desc: '你於 2026年8月30日出售債券,交易價格為105%', lines: [ { label: '當債券價格為105%時的債券面額', value: 'US$100,000 x 105% = US$105,000' }, { label: '應收款項', value: 'US$3,000 + US$105,000 = US$ 108,000' }, ], totalLabel: '總投資回報', totalValue: 'US$108,000 - US$103,000 = US$ 5,000', }, sell95: { title: '3) 於95%出售', badge: '損失', badgeClass: 'bond-badge--loss', desc: '你於 2027年3月31日出售債券,交易價格為95%', lines: [ { label: '當債券價格為95%時的債券面額', value: 'US$100,000 x 95% = US$95,000' }, { label: '應收款項', value: '(US$3,000 X 2) + US$95,000 = US$ 101,000' }, ], totalLabel: '總投資損失', totalValue: 'US$101,000 - US$103,000 = (US$ 2,000)', }, default: { title: '4) 發行人違約', badgeClass: '', desc: '發行人可能無法按時向您支付利息或本金。在最壞的情況下,如果發行人對債券違約,您可能無法收回利息和本金。', lines: [], totalLabel: '', totalValue: '', }, }, }, sc: { all: '全部', loading: '加载中…', emptyCategory: '此类别暂无债券。', emptyFilter: '没有符合所选条件的债券。', loadError: '未能加载债券资料,请稍后再试。', maturity: '到期日', coupon: '票息', risk: '产品风险', gain: '回报', loss: '损失', columns: { isin: 'ISIN', name: '债券名称', currency: '货币', couponPct: '票息 (%)', freq: '派息频率', riskLevel: '产品风险', years: '余下年期', }, scenarios: { hold: { title: '1) 持有直至到期日', badge: '回报', badgeClass: '', desc: '在债券到期前,你将会每半年收到一次利息,并于到期日当天取回债券面额。', lines: [ { label: '债券到期面额', value: 'US$100,000' }, { label: '到期时应收款项', value: '(US$3,000 X 8) + US$100,000 = US$ 124,000' }, ], totalLabel: '总投资回报', totalValue: 'US$124,000 - US$103,000 = US$ 21,000', }, sell105: { title: '2) 于105%出售', badge: '回报', badgeClass: '', desc: '你于 2026年8月30日出售债券,交易价格为105%', lines: [ { label: '当债券价格为105%时的债券面额', value: 'US$100,000 x 105% = US$105,000' }, { label: '应收款项', value: 'US$3,000 + US$105,000 = US$ 108,000' }, ], totalLabel: '总投资回报', totalValue: 'US$108,000 - US$103,000 = US$ 5,000', }, sell95: { title: '3) 于95%出售', badge: '损失', badgeClass: 'bond-badge--loss', desc: '你于 2027年3月31日出售债券,交易价格为95%', lines: [ { label: '当债券价格为95%时的债券面额', value: 'US$100,000 x 95% = US$95,000' }, { label: '应收款项', value: '(US$3,000 X 2) + US$95,000 = US$ 101,000' }, ], totalLabel: '总投资损失', totalValue: 'US$101,000 - US$103,000 = (US$ 2,000)', }, default: { title: '4) 发行人违约', badgeClass: '', desc: '发行人可能无法按时向您支付利息或本金。在最坏的情况下,如果发行人对债券违约,您可能无法收回利息和本金。', lines: [], totalLabel: '', totalValue: '', }, }, }, }; var t = I18N[LANG] || I18N.en; var TOP_TABS = { sovereign: function (row) { return /sovereign/i.test(getField(row, 'Sector') || getField(row, 'Bond Type') || ''); }, corporate: function (row) { return /corporate/i.test(getField(row, 'Sector') || getField(row, 'Bond Type') || ''); }, hkd: function (row) { var sector = getField(row, 'Sector') || getField(row, 'Bond Type') || ''; var currency = getField(row, 'Currency') || ''; return /hkd/i.test(sector) || /^hkd$/i.test(String(currency).trim()); }, }; var TOP_COLUMNS = [ { key: 'ISIN', label: t.columns.isin }, { key: 'Bond Name', label: t.columns.name, alt: ['Product Name', 'Product Name', 'Bond Name '] }, { key: 'Currency', label: t.columns.currency, alt: ['Currency '] }, { key: 'Coupon (%)', label: t.columns.couponPct, alt: ['Coupon', 'Coupon '], kind: 'coupon' }, { key: 'Coupon Frequence', label: t.columns.freq, alt: ['Coupon Frequency', 'Coupon Frequence '] }, { key: 'Product Risk Level', label: t.columns.riskLevel, kind: 'risk' }, { key: 'Years to maturity', label: t.columns.years, alt: ['Years to Maturity'], kind: 'years' }, ]; var ONSHELF_KEYS = { type: 'Sector', typeAlt: 'Bond Type', isin: 'ISIN', name: 'Bond Name', currency: 'Currency', coupon: 'Coupon (%)', couponAlt: 'Coupon', freq: 'Coupon Frequence', years: 'Years to maturity', risk: 'Product Risk Level', }; var SCENARIOS = t.scenarios; var topSellingRows = []; var onShelfRows = []; var activeTopTab = 'sovereign'; var onShelfSort = 'maturity'; var onShelfSortDir = 'asc'; var els = { topSellingTabs: document.getElementById('topSellingTabs'), topSellingBody: document.getElementById('topSellingBody'), scenarioTabs: document.getElementById('scenarioTabs'), scenarioPanel: document.getElementById('scenarioPanel'), filterBondType: document.getElementById('filterBondType'), filterCurrency: document.getElementById('filterCurrency'), filterRisk: document.getElementById('filterRisk'), onshelfSortBtns: document.getElementById('onshelfSortBtns'), onshelfBody: document.getElementById('onshelfBody'), }; function normalizeKey(key) { return String(key || '').replace(/\s+/g, ' ').trim(); } function normalizeRows(json) { return json.map(function (row) { var out = {}; Object.keys(row).forEach(function (k) { out[normalizeKey(k)] = row[k]; }); return out; }); } function getField(row, key) { if (!row) return ''; var wanted = normalizeKey(key); if (row[wanted] != null && row[wanted] !== '') return row[wanted]; var keys = Object.keys(row); for (var i = 0; i < keys.length; i++) { var compact = normalizeKey(keys[i]); if (compact === wanted) return row[keys[i]]; if (compact.indexOf(wanted) === 0 || wanted.indexOf(compact) === 0) { if (row[keys[i]] != null && row[keys[i]] !== '') return row[keys[i]]; } } return ''; } function pickValue(row, col) { var keys = [col.key].concat(col.alt || []); for (var i = 0; i < keys.length; i++) { var v = getField(row, keys[i]); if (v !== '') return v; } return ''; } function parseNumber(value) { if (value == null || value === '') return null; var n = Number(String(value).replace(/[,%$]/g, '')); return Number.isFinite(n) ? n : null; } function couponSortValue(value) { var n = parseNumber(value); if (n == null) return -Infinity; return n > 0 && n < 1 ? n * 100 : n; } function formatCoupon(value) { var n = parseNumber(value); if (n == null) return value ? String(value) : '—'; var pct = n > 0 && n < 1 ? n * 100 : n; return pct.toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 3 }); } function formatRisk(value) { var raw = String(value || '').trim(); if (!raw) return '—'; if (/^lv\s*/i.test(raw)) return raw.replace(/^lv\s*/i, 'Lv '); if (/^\d+$/.test(raw)) return 'Lv ' + raw; return raw; } function formatYears(value) { var n = parseNumber(value); if (n == null) return value ? String(value) : '—'; return n.toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 2 }); } function formatFreq(value) { var v = String(value || '').trim(); if (!v) return '—'; if (/^s\/?a$/i.test(v) || /semi/i.test(v)) return 'S/A'; if (/^a$/i.test(v) || /annual/i.test(v)) return 'A'; if (/^q$/i.test(v) || /quarter/i.test(v)) return 'Q'; return v; } function displayTopCell(row, col) { var value = pickValue(row, col); if (value === '') return '—'; if (col.kind === 'coupon') return formatCoupon(value); if (col.kind === 'risk') return formatRisk(value); if (col.kind === 'years') return formatYears(value); if (col.key.indexOf('Frequence') !== -1 || col.key.indexOf('Frequency') !== -1) return formatFreq(value); return String(value); } function sheetToJson(workbook, preferredNames) { for (var i = 0; i < preferredNames.length; i++) { var name = preferredNames[i]; var trimmed = workbook.SheetNames.find(function (s) { return s.trim() === name.trim(); }); if (trimmed) { return normalizeRows(XLSX.utils.sheet_to_json(workbook.Sheets[trimmed], { defval: '', raw: false })); } } return []; } function escapeHtml(str) { return String(str) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } function uniqueSorted(values) { return Array.from(new Set(values.filter(Boolean))).sort(function (a, b) { return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: 'base' }); }); } function fillSelect(select, values, allLabel) { if (!select) return; var current = select.value; select.innerHTML = ''; values.forEach(function (v) { var opt = document.createElement('option'); opt.value = v; opt.textContent = v; select.appendChild(opt); }); if (current && values.indexOf(current) !== -1) select.value = current; } function showError(msg) { if (els.topSellingBody) { els.topSellingBody.innerHTML = '' + escapeHtml(msg) + ''; } if (els.onshelfBody) { els.onshelfBody.innerHTML = '' + escapeHtml(msg) + ''; } } function renderTopSelling() { if (!els.topSellingBody) return; var matcher = TOP_TABS[activeTopTab] || TOP_TABS.sovereign; var rows = topSellingRows.filter(matcher); if (!rows.length) { els.topSellingBody.innerHTML = '' + escapeHtml(t.emptyCategory) + ''; return; } els.topSellingBody.innerHTML = rows .map(function (row) { var cells = TOP_COLUMNS.map(function (c) { var val = displayTopCell(row, c); var cls = ''; if (c.kind === 'coupon' || c.kind === 'years') cls = 'num'; if (c.key === 'ISIN') cls = 'isin'; if (c.kind === 'risk') { return '' + escapeHtml(val) + ''; } return '' + escapeHtml(val) + ''; }).join(''); return '' + cells + ''; }) .join(''); } function setActiveTopTab(tab) { activeTopTab = tab; if (!els.topSellingTabs) return; els.topSellingTabs.querySelectorAll('.bond-tab').forEach(function (btn) { var active = btn.getAttribute('data-tab') === tab; btn.classList.toggle('active', active); btn.setAttribute('aria-selected', active ? 'true' : 'false'); }); renderTopSelling(); } function setupOnShelfFilters() { fillSelect( els.filterBondType, uniqueSorted( onShelfRows.map(function (r) { return String(getField(r, ONSHELF_KEYS.type) || getField(r, ONSHELF_KEYS.typeAlt) || '').trim(); }) ), t.all ); fillSelect( els.filterCurrency, uniqueSorted( onShelfRows.map(function (r) { return String(getField(r, ONSHELF_KEYS.currency) || '').trim(); }) ), t.all ); fillSelect( els.filterRisk, uniqueSorted( onShelfRows.map(function (r) { return formatRisk(getField(r, ONSHELF_KEYS.risk)); }) ).filter(function (v) { return v !== '—'; }), t.all ); } function updateOnShelfSortButtons() { if (!els.onshelfSortBtns) return; els.onshelfSortBtns.querySelectorAll('.bond-sort-btn').forEach(function (btn) { var key = btn.getAttribute('data-sort'); var active = key === onShelfSort; btn.classList.toggle('active', active); if (key === 'maturity') { btn.textContent = t.maturity + ' ' + (active && onShelfSortDir === 'desc' ? '↓' : '↑'); } else if (key === 'coupon') { btn.textContent = t.coupon + (active ? (onShelfSortDir === 'asc' ? ' ↑' : ' ↓') : ''); } else if (key === 'risk') { btn.textContent = t.risk + (active ? (onShelfSortDir === 'asc' ? ' ↑' : ' ↓') : ''); } }); } function renderOnShelf() { if (!els.onshelfBody) return; var typeFilter = els.filterBondType ? els.filterBondType.value : ''; var currencyFilter = els.filterCurrency ? els.filterCurrency.value : ''; var riskFilter = els.filterRisk ? els.filterRisk.value : ''; var rows = onShelfRows.filter(function (row) { var type = String(getField(row, ONSHELF_KEYS.type) || getField(row, ONSHELF_KEYS.typeAlt) || '').trim(); var currency = String(getField(row, ONSHELF_KEYS.currency) || '').trim(); var risk = formatRisk(getField(row, ONSHELF_KEYS.risk)); if (typeFilter && type !== typeFilter) return false; if (currencyFilter && currency !== currencyFilter) return false; if (riskFilter && risk !== riskFilter) return false; return true; }); rows = rows.slice().sort(function (a, b) { var cmp = 0; if (onShelfSort === 'coupon') { cmp = couponSortValue(getField(a, ONSHELF_KEYS.coupon) || getField(a, ONSHELF_KEYS.couponAlt)) - couponSortValue(getField(b, ONSHELF_KEYS.coupon) || getField(b, ONSHELF_KEYS.couponAlt)); } else if (onShelfSort === 'risk') { var ra = parseNumber(String(getField(a, ONSHELF_KEYS.risk)).replace(/[^\d.]/g, '')) || 0; var rb = parseNumber(String(getField(b, ONSHELF_KEYS.risk)).replace(/[^\d.]/g, '')) || 0; cmp = ra - rb; } else { var ya = parseNumber(getField(a, ONSHELF_KEYS.years)); var yb = parseNumber(getField(b, ONSHELF_KEYS.years)); cmp = (ya == null ? Infinity : ya) - (yb == null ? Infinity : yb); } return onShelfSortDir === 'asc' ? cmp : -cmp; }); updateOnShelfSortButtons(); if (!rows.length) { els.onshelfBody.innerHTML = '' + escapeHtml(t.emptyFilter) + ''; return; } els.onshelfBody.innerHTML = rows .map(function (row) { var isin = getField(row, ONSHELF_KEYS.isin); var name = getField(row, ONSHELF_KEYS.name); var currency = getField(row, ONSHELF_KEYS.currency); var coupon = formatCoupon(getField(row, ONSHELF_KEYS.coupon) || getField(row, ONSHELF_KEYS.couponAlt)); var freq = formatFreq(getField(row, ONSHELF_KEYS.freq)); var years = formatYears(getField(row, ONSHELF_KEYS.years)); var risk = formatRisk(getField(row, ONSHELF_KEYS.risk)); return ( '' + '' + escapeHtml(isin || '—') + '' + '' + escapeHtml(name || '—') + '' + '' + escapeHtml(currency || '—') + '' + '' + escapeHtml(coupon) + '' + '' + escapeHtml(freq) + '' + '' + escapeHtml(risk) + '' + '' + escapeHtml(years) + '' + '' ); }) .join(''); } function renderScenario(key) { var s = SCENARIOS[key]; if (!s || !els.scenarioPanel) return; els.scenarioTabs.querySelectorAll('.bond-scenario-tab').forEach(function (btn) { btn.classList.toggle('active', btn.getAttribute('data-scenario') === key); }); var linesHtml = (s.lines || []) .map(function (line) { return ( '
' + escapeHtml(line.label) + '' + escapeHtml(line.value) + '
' ); }) .join(''); var totalHtml = ''; if (s.totalLabel && s.totalValue) { totalHtml = '
' + escapeHtml(s.totalLabel) + '' + escapeHtml(s.totalValue) + '
'; } els.scenarioPanel.innerHTML = '
' + '

' + escapeHtml(s.title) + '

' + '
' + '

' + escapeHtml(s.desc) + '

' + (linesHtml ? '
' + linesHtml + '
' : '') + totalHtml; } async function loadData() { if (typeof XLSX === 'undefined') { showError('Unable to load page resources. Please refresh.'); return; } if (window.location.protocol === 'file:') { showError('Open via local server to load bond data.'); return; } try { var res = await fetch(DATA_FILE, { cache: 'no-store' }); if (!res.ok) throw new Error('HTTP ' + res.status); var buf = await res.arrayBuffer(); var workbook = XLSX.read(buf, { type: 'array', cellDates: true }); topSellingRows = sheetToJson(workbook, ['Top selling bond', 'Top selling bond ']); onShelfRows = sheetToJson(workbook, ['On-shelf Bond List', 'On-shelf Bond List ', 'Bond List']); if (!topSellingRows.length) throw new Error('No Top Selling data found'); setActiveTopTab(activeTopTab); if (onShelfRows.length) { setupOnShelfFilters(); renderOnShelf(); } else if (els.onshelfBody) { els.onshelfBody.innerHTML = 'No On-Shelf bond data found.'; } } catch (err) { showError(t.loadError); console.error(err); } } if (els.topSellingTabs) { els.topSellingTabs.addEventListener('click', function (e) { var btn = e.target.closest('.bond-tab'); if (btn) setActiveTopTab(btn.getAttribute('data-tab')); }); } if (els.scenarioTabs) { els.scenarioTabs.addEventListener('click', function (e) { var btn = e.target.closest('.bond-scenario-tab'); if (btn) renderScenario(btn.getAttribute('data-scenario')); }); renderScenario('hold'); } ['filterBondType', 'filterCurrency', 'filterRisk'].forEach(function (id) { if (els[id]) els[id].addEventListener('change', renderOnShelf); }); if (els.onshelfSortBtns) { els.onshelfSortBtns.addEventListener('click', function (e) { var btn = e.target.closest('.bond-sort-btn'); if (!btn) return; var key = btn.getAttribute('data-sort'); if (onShelfSort === key) { onShelfSortDir = onShelfSortDir === 'asc' ? 'desc' : 'asc'; } else { onShelfSort = key; onShelfSortDir = key === 'coupon' ? 'desc' : 'asc'; } renderOnShelf(); }); } document.querySelectorAll('.bond-top').forEach(function (link) { link.addEventListener('click', function (e) { e.preventDefault(); window.scrollTo({ top: 0, behavior: 'smooth' }); }); }); document.querySelectorAll('.bond-anchor').forEach(function (link) { link.addEventListener('click', function (e) { var href = link.getAttribute('href') || ''; if (href.charAt(0) !== '#') return; var target = document.getElementById(href.slice(1)); if (!target) return; e.preventDefault(); var offset = 120; var top = target.getBoundingClientRect().top + window.pageYOffset - offset; window.scrollTo({ top: Math.max(0, top), behavior: 'smooth' }); if (history.replaceState) { history.replaceState(null, '', href); } }); }); loadData(); })();