(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(s.desc) + '
' + (linesHtml ? '