Files
sauc a3817dc462 Initial commit: FX Risk Terminal
Multi-currency FX risk engine + browser dashboard:
- Live USD valuation of a multi-currency equity book (ECB rates, no API key)
- Value-at-Risk by 3 methods (parametric, historical, Monte Carlo)
- Expected Shortfall, component VaR, diversification ratio
- Monte Carlo via from-scratch Cholesky (pure Python, no numpy)
- Historical stress testing + minimum-variance hedge search
- Interactive in-browser portfolio builder (stateless, localStorage)
- 20 offline unit tests

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 22:12:00 -04:00

667 lines
45 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>FX Risk Terminal</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.min.js"></script>
<style>
:root {
--bg:#090d14; --surface:#111824; --surface2:#18212f; --border:#1d2a40; --border2:#25344e;
--text:#e2ecf7; --muted:#6b89ab; --dim:#41597a;
--green:#34d399; --green-dim:rgba(52,211,153,.12);
--red:#f0564f; --red-dim:rgba(240,86,79,.12);
--yellow:#f0b541; --yellow-dim:rgba(240,181,65,.12);
--blue:#56a5ec; --blue-dim:rgba(86,165,236,.12);
--purple:#a98af0; --accent:#2680d4; --radius:9px;
--shadow:0 1px 3px rgba(0,0,0,.5),0 6px 20px rgba(0,0,0,.28);
}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
body{background:radial-gradient(1200px 600px at 80% -10%,rgba(38,128,212,.06),transparent 60%),var(--bg);
color:var(--text);font-family:'SF Pro Text',-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif;
font-size:14px;line-height:1.5;min-height:100vh;-webkit-font-smoothing:antialiased}
.mono{font-variant-numeric:tabular-nums;font-feature-settings:"tnum"}
.page{max-width:1240px;margin:0 auto;padding:26px 22px 64px}
.header{display:flex;justify-content:space-between;align-items:center;margin-bottom:22px;gap:16px;flex-wrap:wrap}
.brand{display:flex;align-items:center;gap:13px}
.brand-mark{width:38px;height:38px;border-radius:9px;flex-shrink:0;
background:linear-gradient(140deg,#2680d4,#56a5ec 55%,#34d399);display:grid;place-items:center;
font-weight:800;font-size:17px;color:#06101e;box-shadow:0 4px 14px rgba(38,128,212,.4)}
.header-left h1{font-size:19px;font-weight:700;letter-spacing:-.3px}
.header-left p{font-size:12px;color:var(--muted);margin-top:1px}
.header-right{display:flex;align-items:center;gap:14px;font-size:12px;color:var(--muted)}
.live-pill{display:flex;align-items:center;gap:7px;background:var(--surface);border:1px solid var(--border);padding:6px 11px;border-radius:20px}
.live-dot{width:7px;height:7px;border-radius:50%;background:var(--muted);flex-shrink:0}
.live-dot.active{background:var(--green);box-shadow:0 0 8px var(--green);animation:blink 2.4s ease-in-out infinite}
.live-dot.error{background:var(--red);box-shadow:0 0 8px var(--red)}
@keyframes blink{0%,100%{opacity:1}50%{opacity:.35}}
.row{display:grid;gap:14px;margin-bottom:14px}
.g4{grid-template-columns:repeat(4,1fr)} .g3{grid-template-columns:repeat(3,1fr)}
.g2{grid-template-columns:repeat(2,1fr)} .g23{grid-template-columns:1.7fr 1fr} .g32{grid-template-columns:1fr 1.4fr}
@media(max-width:860px){.g4{grid-template-columns:repeat(2,1fr)}.g3,.g2,.g23,.g32{grid-template-columns:1fr}}
@media(max-width:520px){.g4{grid-template-columns:1fr}}
.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);box-shadow:var(--shadow)}
.card-head{display:flex;justify-content:space-between;align-items:center;padding:13px 17px;border-bottom:1px solid var(--border)}
.card-title{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.09em;color:var(--muted)}
.card-sub{font-size:10.5px;color:var(--dim)}
.card-body{padding:17px}
.kpi{padding:16px 18px;position:relative;overflow:hidden}
.kpi-label{font-size:10.5px;font-weight:600;letter-spacing:.07em;text-transform:uppercase;color:var(--muted);display:flex;align-items:center;gap:6px}
.kpi-value{font-size:28px;font-weight:800;letter-spacing:-.03em;margin-top:8px;line-height:1}
.kpi-sub{font-size:11.5px;color:var(--muted);margin-top:7px}
.kpi-accent{position:absolute;left:0;top:0;bottom:0;width:3px}
.kpi-accent.blue{background:var(--blue)}.kpi-accent.red{background:var(--red)}
.kpi-accent.yellow{background:var(--yellow)}.kpi-accent.green{background:var(--green)}
.tag{font-size:9.5px;font-weight:700;padding:1px 6px;border-radius:4px}
.tag-red{background:var(--red-dim);color:var(--red)}.tag-green{background:var(--green-dim);color:var(--green)}.tag-yellow{background:var(--yellow-dim);color:var(--yellow)}
/* rate tiles */
.rate-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(186px,1fr));gap:12px}
.rate-tile{position:relative;overflow:hidden;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:13px 15px;box-shadow:var(--shadow)}
.rate-pair{font-size:10px;font-weight:700;letter-spacing:.09em;text-transform:uppercase;color:var(--muted);margin-bottom:5px}
.rate-value{font-size:22px;font-weight:800;letter-spacing:-.02em;line-height:1}
.rate-meta{display:flex;align-items:center;gap:7px;margin-top:7px;flex-wrap:wrap}
.pill{font-size:10.5px;font-weight:700;padding:2px 6px;border-radius:4px}
.pill.pos{background:var(--green-dim);color:var(--green)}.pill.neg{background:var(--red-dim);color:var(--red)}.pill.neu{background:var(--blue-dim);color:var(--muted)}
.flag-tag{font-size:9px;font-weight:700;padding:1px 5px;border-radius:4px;background:var(--yellow-dim);color:var(--yellow);border:1px solid rgba(240,181,65,.3)}
.rate-spark{position:absolute;right:0;bottom:0;opacity:.5}
.rate-accent{position:absolute;bottom:0;left:0;height:2px;width:100%}
.table-wrap{overflow-x:auto}
table{width:100%;border-collapse:collapse;font-size:13px}
thead th{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);padding:11px 17px;text-align:right;white-space:nowrap;background:var(--surface2)}
thead th:first-child{text-align:left}
tbody tr{border-top:1px solid var(--border)}
tbody tr:hover{background:rgba(255,255,255,.018)}
tbody td{padding:12px 17px;text-align:right;font-variant-numeric:tabular-nums;white-space:nowrap}
tbody td:first-child{text-align:left}
.ticker-cell{display:flex;align-items:center;gap:8px}
.ticker{font-weight:700;font-size:14px;color:var(--blue)}
.badge{font-size:9px;font-weight:700;padding:2px 5px;border-radius:3px;letter-spacing:.04em;text-transform:uppercase}
.badge-flag{background:var(--yellow-dim);color:var(--yellow);border:1px solid rgba(240,181,65,.3)}
.pos{color:var(--green)}.neg{color:var(--red)}.dim{color:var(--muted);font-size:12px}
.total-row td{font-weight:700;border-top:2px solid var(--border2);color:var(--text)}
.var-table td.method{text-align:left;font-weight:600;color:var(--text)}
.chart-wrap{padding:14px 16px 16px}
canvas{display:block}
.contrib-row{display:flex;align-items:center;gap:10px;margin-bottom:11px}
.contrib-label{width:38px;font-size:12px;font-weight:700}
.contrib-track{flex:1;height:20px;background:var(--surface2);border-radius:5px;overflow:hidden}
.contrib-fill{height:100%;border-radius:5px;display:flex;align-items:center;padding-left:8px;font-size:10.5px;font-weight:700;color:#06101e;transition:width .5s cubic-bezier(.4,0,.2,1)}
.contrib-val{width:66px;text-align:right;font-size:11.5px;font-variant-numeric:tabular-nums}
.heat{display:grid;gap:4px;font-size:11px}
.heat-cell{padding:9px 4px;border-radius:5px;text-align:center;font-weight:700;font-variant-numeric:tabular-nums}
.heat-hdr{color:var(--muted);font-weight:600;display:grid;place-items:center;font-size:10.5px}
.stress-bar{display:inline-block;height:7px;border-radius:4px;vertical-align:middle}
.scenario-grid{display:grid;grid-template-columns:1fr 1fr;gap:22px}
@media(max-width:620px){.scenario-grid{grid-template-columns:1fr}}
.slider-list{display:flex;flex-direction:column;gap:9px;max-height:230px;overflow-y:auto;padding-right:4px}
.slider-row{display:flex;align-items:center;gap:9px}
.slider-ccy{width:34px;font-size:11px;font-weight:700}
input[type=range]{flex:1;-webkit-appearance:none;height:4px;background:var(--border2);border-radius:3px;outline:none}
input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:14px;height:14px;border-radius:50%;background:var(--blue);cursor:pointer;border:2px solid #0b1320}
.slider-val{font-size:11.5px;font-weight:700;font-variant-numeric:tabular-nums;min-width:46px;text-align:right}
.scenario-output{display:flex;flex-direction:column;gap:8px;max-height:230px;overflow-y:auto}
.scenario-row{display:flex;justify-content:space-between;align-items:center;padding:8px 12px;background:var(--surface2);border-radius:6px;font-size:12.5px}
.scenario-row .s-ticker{font-weight:600;color:var(--muted)}
.scenario-total{display:flex;justify-content:space-between;align-items:center;padding:11px 12px;border-radius:6px;border:1px solid var(--border2);background:linear-gradient(180deg,var(--surface2),var(--surface));font-size:14px;font-weight:700;position:sticky;bottom:0}
.scenario-total span:first-child{color:var(--muted)}
.preset-btns{display:flex;gap:7px;flex-wrap:wrap;margin-top:12px}
.preset{background:var(--surface2);border:1px solid var(--border);color:var(--muted);padding:5px 11px;border-radius:6px;font-size:11.5px;cursor:pointer;font-family:inherit;transition:all .12s}
.preset:hover{color:var(--text);border-color:var(--blue)}
.hedge-stat{display:flex;justify-content:space-between;align-items:baseline;padding:9px 0;border-bottom:1px solid var(--border)}
.hedge-stat:last-of-type{border-bottom:none}
.hedge-k{font-size:12px;color:var(--muted)}
.hedge-v{font-size:15px;font-weight:700;font-variant-numeric:tabular-nums}
.effbar{height:8px;border-radius:5px;background:var(--surface2);overflow:hidden;margin-top:10px}
.effbar-fill{height:100%;background:linear-gradient(90deg,var(--green),var(--blue));border-radius:5px}
.volgrid{display:grid;grid-template-columns:repeat(auto-fit,minmax(74px,1fr));gap:10px}
.vol-cell{text-align:center;background:var(--surface2);border-radius:7px;padding:10px 6px}
.vol-ccy{font-size:10px;font-weight:700;color:var(--muted)}
.vol-num{font-size:17px;font-weight:800;margin-top:4px;font-variant-numeric:tabular-nums}
.seg{display:flex;background:var(--surface);border:1px solid var(--border);border-radius:7px;overflow:hidden}
.seg button{background:transparent;border:none;color:var(--muted);padding:5px 12px;font-size:11.5px;font-weight:600;cursor:pointer;font-family:inherit}
.seg button.active{background:var(--accent);color:#fff}
.skel{display:inline-block;background:var(--border);border-radius:4px;animation:skp 1.4s ease-in-out infinite}
@keyframes skp{0%,100%{opacity:.35}50%{opacity:.7}}
.footer{margin-top:30px;display:flex;justify-content:space-between;font-size:11px;color:var(--dim);flex-wrap:wrap;gap:6px;line-height:1.6}
.footer a{color:var(--dim);text-decoration:none}.footer a:hover{color:var(--muted)}
.note{font-size:11px;color:var(--dim);margin-top:11px;line-height:1.5}
/* header buttons */
.ghost-btn{background:var(--surface);border:1px solid var(--border);color:var(--muted);padding:6px 12px;border-radius:7px;font-size:12px;font-weight:600;cursor:pointer;font-family:inherit;transition:all .12s}
.ghost-btn:hover{color:var(--text);border-color:var(--blue)}
.book-pill{display:inline-flex;align-items:center;gap:8px;background:var(--blue-dim);color:var(--blue);border:1px solid rgba(86,165,236,.35);padding:5px 10px;border-radius:20px;font-size:11.5px;font-weight:700}
.book-pill #book-reset{cursor:pointer;font-weight:800;opacity:.8}
.book-pill #book-reset:hover{opacity:1}
/* builder modal */
.modal-overlay{position:fixed;inset:0;background:rgba(4,7,12,.72);backdrop-filter:blur(3px);display:none;align-items:flex-start;justify-content:center;z-index:50;padding:40px 16px;overflow-y:auto}
.modal-overlay.open{display:flex}
.modal{background:var(--surface);border:1px solid var(--border2);border-radius:12px;width:100%;max-width:760px;box-shadow:0 20px 60px rgba(0,0,0,.6)}
.modal-head{display:flex;justify-content:space-between;align-items:center;padding:18px 22px;border-bottom:1px solid var(--border)}
.modal-head h2{font-size:15px;font-weight:700}
.modal-head p{font-size:11.5px;color:var(--muted);margin-top:2px}
.modal-x{background:none;border:none;color:var(--muted);font-size:22px;cursor:pointer;line-height:1}
.modal-body{padding:18px 22px;max-height:60vh;overflow-y:auto}
.builder-table{width:100%;border-collapse:collapse;font-size:12.5px}
.builder-table th{text-align:left;font-size:10px;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);padding:0 8px 8px;font-weight:600}
.builder-table td{padding:4px 6px}
.builder-table input,.builder-table select{width:100%;background:var(--bg);border:1px solid var(--border);color:var(--text);padding:7px 9px;border-radius:6px;font-size:12.5px;font-family:inherit;outline:none}
.builder-table input:focus,.builder-table select:focus{border-color:var(--blue)}
.builder-table .num{text-align:right;font-variant-numeric:tabular-nums}
.row-x{background:none;border:none;color:var(--dim);cursor:pointer;font-size:16px;padding:4px 6px}
.row-x:hover{color:var(--red)}
.add-row{margin-top:12px;background:var(--surface2);border:1px dashed var(--border2);color:var(--muted);padding:8px 14px;border-radius:7px;font-size:12px;cursor:pointer;font-family:inherit;width:100%}
.add-row:hover{color:var(--text);border-color:var(--blue)}
.modal-foot{display:flex;justify-content:space-between;align-items:center;gap:10px;padding:16px 22px;border-top:1px solid var(--border);flex-wrap:wrap}
.modal-err{font-size:11.5px;color:var(--yellow)}
.btn{padding:8px 16px;border-radius:7px;font-size:12.5px;font-weight:600;cursor:pointer;font-family:inherit;border:1px solid var(--border)}
.btn-ghost{background:var(--surface2);color:var(--muted)}
.btn-ghost:hover{color:var(--text)}
.btn-primary{background:var(--accent);border-color:var(--accent);color:#fff}
.btn-primary:hover{background:var(--blue)}
</style>
</head>
<body>
<div class="page">
<div class="header">
<div class="brand">
<div class="brand-mark">FX</div>
<div class="header-left">
<h1>FX Risk Terminal</h1>
<p>Multi-currency equity book · live USD valuation · VaR · stress · hedging</p>
</div>
</div>
<div class="header-right">
<span class="book-pill" id="book-pill" style="display:none">Custom book<span id="book-reset" title="Reset to default">×</span></span>
<button class="ghost-btn" id="edit-book-btn">✎ Edit book</button>
<div class="seg" id="horizon-seg"><button data-h="1" class="active">1-Day</button><button data-h="10">10-Day</button></div>
<div class="live-pill"><span class="live-dot" id="live-dot"></span><span id="last-updated">Loading…</span><span id="countdown" style="color:var(--dim)"></span></div>
</div>
</div>
<!-- KPI hero -->
<div class="row g4">
<div class="card kpi"><div class="kpi-accent blue"></div>
<div class="kpi-label">Book Value (USD)</div>
<div class="kpi-value mono" id="kpi-value"><span class="skel" style="width:110px;height:28px"></span></div>
<div class="kpi-sub" id="kpi-value-sub">FX P&amp;L today —</div></div>
<div class="card kpi"><div class="kpi-accent red"></div>
<div class="kpi-label">1-Day VaR <span class="tag tag-red">95%</span></div>
<div class="kpi-value mono" id="kpi-var"><span class="skel" style="width:90px;height:28px"></span></div>
<div class="kpi-sub" id="kpi-var-sub">Monte Carlo · 50k paths</div></div>
<div class="card kpi"><div class="kpi-accent yellow"></div>
<div class="kpi-label">Expected Shortfall <span class="tag tag-yellow">95%</span></div>
<div class="kpi-value mono" id="kpi-es"><span class="skel" style="width:90px;height:28px"></span></div>
<div class="kpi-sub" id="kpi-es-sub">Mean loss beyond VaR</div></div>
<div class="card kpi"><div class="kpi-accent green"></div>
<div class="kpi-label">Portfolio Vol <span class="tag tag-green">ann.</span></div>
<div class="kpi-value mono" id="kpi-vol"><span class="skel" style="width:70px;height:28px"></span></div>
<div class="kpi-sub" id="kpi-vol-sub">diversification —</div></div>
</div>
<!-- Rates -->
<div class="row"><div class="rate-grid" id="rate-grid">
<div class="skel" style="height:88px;border-radius:9px"></div>
<div class="skel" style="height:88px;border-radius:9px"></div>
<div class="skel" style="height:88px;border-radius:9px"></div>
<div class="skel" style="height:88px;border-radius:9px"></div>
</div></div>
<!-- VaR + risk contribution -->
<div class="row g23">
<div class="card">
<div class="card-head"><span class="card-title">Value-at-Risk · 3 Methodologies</span><span class="card-sub" id="var-horizon-label">1-day horizon</span></div>
<div class="table-wrap"><table class="var-table"><thead><tr><th>Method</th><th>95% VaR</th><th>99% VaR</th><th>95% ES</th><th>% of book</th></tr></thead>
<tbody id="var-body"><tr><td colspan="5" style="text-align:center;padding:22px;color:var(--muted)">Computing VaR…</td></tr></tbody></table></div>
<div class="note" style="padding:0 17px 15px">Three independent estimators on the same book. Convergence validates the model; divergence flags non-normal (fat-tailed) risk.</div>
</div>
<div class="card">
<div class="card-head"><span class="card-title">Risk Contribution</span><span class="card-sub">component VaR · 95%</span></div>
<div class="card-body"><div id="contrib-bars"><div class="dim">Loading…</div></div>
<div class="note">Each currency's additive share of total VaR. This is where risk concentrates — often not where the money sits.</div></div>
</div>
</div>
<!-- MC distribution + correlation -->
<div class="row g23">
<div class="card"><div class="card-head"><span class="card-title">Monte Carlo P&amp;L Distribution</span><span class="card-sub" id="mc-sub">50,000 correlated paths · Cholesky</span></div>
<div class="chart-wrap"><canvas id="mc-chart" height="120"></canvas></div></div>
<div class="card"><div class="card-head"><span class="card-title">Correlation Matrix</span><span class="card-sub">30d log returns</span></div>
<div class="card-body"><div class="heat" id="corr-heat"><div class="dim">Loading…</div></div></div></div>
</div>
<!-- Stress -->
<div class="row"><div class="card">
<div class="card-head"><span class="card-title">Historical Stress Scenarios</span><span class="card-sub">stylised FX shocks → live book impact</span></div>
<div class="table-wrap"><table><thead><tr><th>Scenario</th><th>Book Impact (USD)</th><th>% of Book</th><th style="width:42%">Severity</th></tr></thead>
<tbody id="stress-body"><tr><td colspan="4" style="text-align:center;padding:22px;color:var(--muted)">Loading scenarios…</td></tr></tbody></table></div>
</div></div>
<!-- Hedge + scenario -->
<div class="row g32">
<div class="card"><div class="card-head"><span class="card-title">Min-Variance Hedge</span><span class="card-sub">best single forward</span></div>
<div class="card-body" id="hedge-body"><div class="dim">Loading…</div></div></div>
<div class="card"><div class="card-head"><span class="card-title">FX Scenario Calculator</span><span class="card-sub">shock any currency</span></div>
<div class="card-body"><div class="scenario-grid">
<div><div class="slider-list" id="slider-list"><div class="dim">Loading…</div></div>
<div class="preset-btns" id="preset-btns"></div></div>
<div class="scenario-output" id="scenario-output"><div style="color:var(--muted);font-size:12px;padding:8px 0">Move a slider or pick a preset.</div></div>
</div></div></div>
</div>
<!-- Indexed performance -->
<div class="row"><div class="card">
<div class="card-head"><span class="card-title">30-Day FX Performance</span><span class="card-sub">indexed to 100 · vs USD · ECB fixings</span></div>
<div class="chart-wrap"><canvas id="perf-chart" height="76"></canvas></div>
</div></div>
<!-- Vol -->
<div class="row g2">
<div class="card"><div class="card-head"><span class="card-title">Rolling Volatility</span><span class="card-sub">10-day window · annualised</span></div>
<div class="chart-wrap"><canvas id="vol-chart" height="150"></canvas></div></div>
<div class="card"><div class="card-head"><span class="card-title">Annualised Volatility</span><span class="card-sub">30-day · color = risk tier</span></div>
<div class="card-body"><div class="volgrid" id="vol-grid"><div class="dim">Loading…</div></div></div></div>
</div>
<!-- Positions -->
<div class="row"><div class="card">
<div class="card-head"><span class="card-title">Positions</span><span class="card-sub" id="total-label">Total —</span></div>
<div class="table-wrap"><table><thead><tr><th>Ticker</th><th>Exch.</th><th>Shares</th><th>Avg Cost</th><th>Cost (local)</th><th>Value (USD)</th><th>Weight</th><th>FX P&amp;L</th><th>FX Δ%</th></tr></thead>
<tbody id="positions-body"><tr><td colspan="9" style="text-align:center;padding:22px;color:var(--muted)">Loading…</td></tr></tbody></table></div>
</div></div>
<div class="footer">
<span>Rates: <a href="https://www.frankfurter.app" target="_blank" rel="noopener">frankfurter.app</a> (ECB) · VaR/ES/Monte-Carlo/Cholesky in pure Python (no numpy) · auto-refresh 60 s</span>
<span id="footer-ts"></span>
</div>
</div>
<!-- Portfolio builder modal -->
<div class="modal-overlay" id="builder-modal">
<div class="modal">
<div class="modal-head">
<div><h2>Portfolio Builder</h2><p>Edit your book and re-run the full risk engine live. Stored only in this browser — nothing is sent to a server or saved to the repo.</p></div>
<button class="modal-x" id="modal-close">×</button>
</div>
<div class="modal-body">
<table class="builder-table">
<thead><tr><th style="width:22%">Ticker</th><th style="width:16%">Shares</th><th style="width:18%">Avg cost</th><th style="width:18%">Currency</th><th style="width:22%">Exchange</th><th></th></tr></thead>
<tbody id="builder-rows"></tbody>
</table>
<button class="add-row" id="add-row">+ Add position</button>
</div>
<div class="modal-foot">
<span class="modal-err" id="builder-err"></span>
<div style="display:flex;gap:8px;margin-left:auto">
<button class="btn btn-ghost" id="load-demo">Load demo book</button>
<button class="btn btn-ghost" id="reset-default">Reset to default</button>
<button class="btn btn-primary" id="apply-book">Apply &amp; analyze</button>
</div>
</div>
</div>
</div>
<script>
const REFRESH_MS = 60_000;
const C = { blue:'#56a5ec', green:'#34d399', red:'#f0564f', yellow:'#f0b541', purple:'#a98af0', muted:'#6b89ab', border:'#1d2a40', surface2:'#18212f' };
/* per-currency color palette */
const PALETTE = { EUR:'#56a5ec', SEK:'#34d399', GBP:'#a98af0', CHF:'#f0564f', JPY:'#f0b541',
KRW:'#2dd4bf', BRL:'#fb923c', MXN:'#f472b6', NOK:'#818cf8', DKK:'#4ade80', USD:'#94a3b8' };
const FALLBACK = ['#60a5fa','#fbbf24','#34d399','#f472b6','#a78bfa','#fb923c','#2dd4bf','#f87171'];
function ccyColor(c, i=0){ return PALETTE[c] || FALLBACK[i % FALLBACK.length]; }
function decFor(v){ return v >= 10 ? 4 : v >= 0.1 ? 5 : 6; } // sig digits by magnitude
let horizon = 1, countdown = REFRESH_MS/1000;
const charts = {};
let CURRENCIES = [];
const fmtPct=(n,d=3)=>n!=null?(n>=0?'+':'')+n.toFixed(d)+'%':'—';
const usd=(n,d=2)=>n!=null?new Intl.NumberFormat('en-US',{style:'currency',currency:'USD',minimumFractionDigits:d,maximumFractionDigits:d}).format(n):'—';
const usd0=n=>n!=null?new Intl.NumberFormat('en-US',{style:'currency',currency:'USD',maximumFractionDigits:0}).format(n):'—';
const loc=(n,c)=>n!=null?new Intl.NumberFormat('en-US',{minimumFractionDigits:2,maximumFractionDigits:2}).format(n)+' '+c:'—';
const signedUsd=n=>n==null?'—':(n>=0?'+':'')+usd(n);
const badge=(c)=>`<span class="badge" style="background:${ccyColor(c)}22;color:${ccyColor(c)}">${c}</span>`;
Chart.defaults.color=C.muted; Chart.defaults.borderColor=C.border;
Chart.defaults.font.family="'SF Pro Text',-apple-system,sans-serif"; Chart.defaults.font.size=11;
const vlinePlugin={id:'vlines',afterDraw(chart){const lines=chart.options.plugins.vlines?.lines||[];
const{ctx,chartArea:{top,bottom},scales:{x}}=chart;lines.forEach(l=>{const px=x.getPixelForValue(l.value);
if(px<x.left||px>x.right)return;ctx.save();ctx.beginPath();ctx.moveTo(px,top);ctx.lineTo(px,bottom);
ctx.lineWidth=1.5;ctx.strokeStyle=l.color;ctx.setLineDash(l.dash||[5,4]);ctx.stroke();ctx.setLineDash([]);
ctx.fillStyle=l.color;ctx.font='600 10px sans-serif';ctx.textAlign=px>(x.left+x.right)/2?'right':'left';
ctx.fillText(l.label,px+(px>(x.left+x.right)/2?-5:5),top+11);ctx.restore();});}};
Chart.register(vlinePlugin);
async function getJSON(u,o){const r=await fetch(u,o);if(!r.ok)throw new Error(`${u}${r.status}`);return r.json();}
/* ── RATES ── */
async function loadRates(){ renderRates(await getJSON('/api/rates')); }
function renderRates(d){
const ccys=Object.keys(d.live.rates);
const grid=document.getElementById('rate-grid');
grid.innerHTML=ccys.map((c,i)=>{
const v=d.live.rates[c], chg=d.change[c], prev=d.prev.rates[c], col=ccyColor(c,i);
const cls=chg==null?'neu':chg>0?'pos':chg<0?'neg':'neu';
return `<div class="rate-tile">
<div class="rate-pair">${c} / USD</div>
<div class="rate-value mono">${v.toFixed(decFor(v))}</div>
<div class="rate-meta"><span class="pill ${cls}">${fmtPct(chg)}</span>
${d.flagged[c]?'<span class="flag-tag">⚡</span>':''}</div>
<canvas class="rate-spark" id="spark-${c}" width="120" height="38"></canvas>
<div class="rate-accent" style="background:linear-gradient(90deg,${col},transparent)"></div></div>`;
}).join('');
document.getElementById('last-updated').textContent=new Date().toLocaleTimeString();
document.getElementById('footer-ts').textContent='Rate date '+(d.live.date||'—')+' · source '+(d.live.source||'—');
document.getElementById('live-dot').className='live-dot active';
}
/* ── POSITIONS ── */
async function loadPositions(){ renderPositions(await getJSON('/api/positions')); }
function renderPositions(d){
const rows=d.positions.map(p=>{
const pnlC=p.fx_pnl_usd==null?'dim':p.fx_pnl_usd>0?'pos':p.fx_pnl_usd<0?'neg':'dim';
const pctC=p.fx_pct==null?'dim':p.fx_pct>0?'pos':p.fx_pct<0?'neg':'dim';
return `<tr><td><div class="ticker-cell"><span class="ticker">${p.ticker}</span>${badge(p.currency)}
${p.flagged?'<span class="badge badge-flag">⚡</span>':''}</div></td>
<td class="dim">${p.exchange}</td><td>${p.shares}</td><td class="dim">${loc(p.avg_cost,p.currency)}</td>
<td>${loc(p.cost_local,p.currency)}</td><td>${usd(p.cost_usd)}</td>
<td class="dim">${p.weight_pct!=null?p.weight_pct.toFixed(1)+'%':'—'}</td>
<td class="${pnlC}">${signedUsd(p.fx_pnl_usd)}</td><td class="${pctC}">${fmtPct(p.fx_pct)}</td></tr>`;
});
rows.push(`<tr class="total-row"><td colspan="5">Total book</td><td>${usd(d.total_cost_usd)}</td><td>100%</td>
<td class="${d.total_fx_pnl_usd>=0?'pos':'neg'}">${signedUsd(d.total_fx_pnl_usd)}</td><td></td></tr>`);
document.getElementById('positions-body').innerHTML=rows.join('');
document.getElementById('kpi-value').textContent=usd0(d.total_cost_usd);
document.getElementById('kpi-value-sub').innerHTML=`FX P&L today <span class="${d.total_fx_pnl_usd>=0?'pos':'neg'}">${signedUsd(d.total_fx_pnl_usd)}</span>`;
const exp=Object.entries(d.exposure_pct).sort((a,b)=>b[1]-a[1]).slice(0,3).map(([c,v])=>`${c} ${v}%`).join(' · ');
document.getElementById('total-label').textContent=`${usd0(d.total_cost_usd)} · ${exp}`;
}
/* ── RISK ── */
async function loadRisk(){
if(customBook){ return refresh(); } // custom book recomputes the whole bundle
renderRisk(await getJSON('/api/risk?horizon='+horizon));
}
function renderRisk(d){
CURRENCIES=d.currencies;
const v=d.var, book=d.total_exposure_usd, pctOf=x=>book?(x/book*100).toFixed(2)+'%':'—';
document.getElementById('kpi-var').textContent=usd0(v.monte_carlo.var95);
document.getElementById('kpi-var-sub').innerHTML=`<span class="neg">${pctOf(v.monte_carlo.var95)}</span> of book · ${horizon}-day · 50k paths`;
document.getElementById('kpi-es').textContent=usd0(v.monte_carlo.es95);
document.getElementById('kpi-es-sub').innerHTML=`<span class="tag tag-yellow">${(v.monte_carlo.es95/v.monte_carlo.var95).toFixed(2)}×</span> the 95% VaR`;
document.getElementById('kpi-vol').textContent=d.portfolio_vol_annual_pct!=null?d.portfolio_vol_annual_pct.toFixed(1)+'%':'—';
document.getElementById('kpi-vol-sub').textContent=d.diversification_ratio!=null?`diversification ratio ${d.diversification_ratio.toFixed(2)}`:'';
const methods=[['Parametric',v.parametric,'variance-covariance'],['Historical',v.historical,'empirical tail'],['Monte Carlo',v.monte_carlo,'50k Cholesky paths']];
document.getElementById('var-body').innerHTML=methods.map(([n,m,s])=>`<tr>
<td class="method">${n}<div class="card-sub" style="font-weight:400">${s}</div></td>
<td class="mono neg">${usd(m.var95)}</td><td class="mono neg">${usd(m.var99)}</td>
<td class="mono">${m.es95!=null?usd(m.es95):'<span class="dim">—</span>'}</td>
<td class="mono">${m.var95_pct!=null?m.var95_pct.toFixed(2)+'%':'—'}</td></tr>`).join('');
document.getElementById('var-horizon-label').textContent=horizon+'-day horizon';
const comp=d.component_var95, tot=Object.values(comp).reduce((a,b)=>a+Math.abs(b),0);
document.getElementById('contrib-bars').innerHTML=Object.entries(comp).sort((a,b)=>Math.abs(b[1])-Math.abs(a[1])).map(([c,val],i)=>{
const share=tot?Math.abs(val)/tot*100:0,col=ccyColor(c,i);
return `<div class="contrib-row"><div class="contrib-label" style="color:${col}">${c}</div>
<div class="contrib-track"><div class="contrib-fill" style="width:${share}%;background:${col}">${share>=8?share.toFixed(0)+'%':''}</div></div>
<div class="contrib-val mono">${usd(val)}</div></div>`;
}).join('');
renderHeat(d.currencies,d.correlation);
renderMcChart(d.distribution);
document.getElementById('mc-sub').textContent=`${v.monte_carlo.n_sims.toLocaleString()} paths · ${horizon}-day`;
renderHedge(d.hedge);
buildSliders(d.currencies);
}
function renderHeat(curr,corr){
const el=document.getElementById('corr-heat');
el.style.gridTemplateColumns=`auto repeat(${curr.length},1fr)`;
const color=r=>{const a=Math.abs(r),base=r>=0?'52,211,153':'240,86,79';return `rgba(${base},${(0.1+a*0.6).toFixed(2)})`};
let html=`<div class="heat-hdr"></div>`+curr.map(c=>`<div class="heat-hdr">${c}</div>`).join('');
curr.forEach((rc,i)=>{html+=`<div class="heat-hdr">${rc}</div>`;
curr.forEach((cc,j)=>{const r=corr[i][j];html+=`<div class="heat-cell mono" style="background:${color(r)};color:${Math.abs(r)>0.55?'#06101e':'var(--text)'}">${r.toFixed(2)}</div>`;});});
el.innerHTML=html;
}
function renderMcChart(dist){
const ctx=document.getElementById('mc-chart').getContext('2d');
if(charts.mc)charts.mc.destroy();
const v95=-dist.var95,v99=-dist.var99;
const bg=dist.centers.map(c=>c<=v99?C.red:c<=v95?'rgba(240,86,79,.55)':'rgba(86,165,236,.55)');
charts.mc=new Chart(ctx,{type:'bar',
data:{labels:dist.centers,datasets:[{data:dist.counts,backgroundColor:bg,barPercentage:1,categoryPercentage:1}]},
options:{responsive:true,plugins:{legend:{display:false},
vlines:{lines:[{value:v95,color:C.yellow,label:'95% VaR'},{value:v99,color:C.red,label:'99% VaR'},{value:0,color:C.muted,label:'',dash:[2,3]}]},
tooltip:{callbacks:{title:i=>'P&L ≈ '+usd0(parseFloat(i[0].label)),label:i=>i.parsed.y.toLocaleString()+' paths'}}},
scales:{x:{type:'linear',grid:{display:false},ticks:{maxTicksLimit:7,callback:v=>usd0(v)}},
y:{grid:{color:C.border},ticks:{maxTicksLimit:5},title:{display:true,text:'paths'}}}}});
}
function renderHedge(h){
const b=h.best_single||{};
document.getElementById('hedge-body').innerHTML=`
<div class="hedge-stat"><span class="hedge-k">Best single instrument</span><span class="hedge-v" style="color:${ccyColor(b.instrument)}">${b.instrument||'—'}/USD</span></div>
<div class="hedge-stat"><span class="hedge-k">Short notional</span><span class="hedge-v mono">${usd0(b.notional_short_usd)}</span></div>
<div class="hedge-stat"><span class="hedge-k">Daily σ (unhedged)</span><span class="hedge-v mono">${usd(h.unhedged_sigma)}</span></div>
<div class="hedge-stat"><span class="hedge-k">Daily σ (hedged)</span><span class="hedge-v mono" style="color:var(--green)">${usd(b.residual_sigma)}</span></div>
<div style="margin-top:12px"><div style="display:flex;justify-content:space-between;font-size:12px;color:var(--muted)">
<span>Hedge effectiveness (variance ↓)</span><span class="mono" style="color:var(--green);font-weight:700">${(b.effectiveness_pct||0).toFixed(1)}%</span></div>
<div class="effbar"><div class="effbar-fill" style="width:${b.effectiveness_pct||0}%"></div></div></div>
<div class="note">A single ${b.instrument}/USD forward absorbs ${(b.effectiveness_pct||0).toFixed(0)}% of the book's FX variance via cross-correlation. Full neutralisation needs a forward per sleeve.</div>`;
}
/* ── HISTORICAL / CHARTS ── */
async function loadHistorical(){ renderHistorical(await getJSON('/api/historical')); }
function renderHistorical(d){
const curr=d.currencies, rates=d.series.rates;
document.getElementById('vol-grid').innerHTML=curr.map((c,i)=>{
const a=d.analytics[c],col={green:C.green,yellow:C.yellow,red:C.red,neutral:C.muted}[a.vol_color]||C.muted;
return `<div class="vol-cell"><div class="vol-ccy" style="color:${ccyColor(c,i)}">${c}/USD</div>
<div class="vol-num" style="color:${col}">${a.vol!=null?a.vol.toFixed(1)+'%':'—'}</div></div>`;}).join('');
renderPerfChart(d.series,curr);
renderVolChart(d.series,curr);
curr.forEach((c,i)=>renderSpark('spark-'+c,rates[c],ccyColor(c,i)));
}
const shortDate=s=>{const[_,m,d]=s.split('-');return `${m}/${d}`};
function renderPerfChart(series,curr){
const ctx=document.getElementById('perf-chart').getContext('2d');
if(charts.perf)charts.perf.destroy();
const ds=curr.map((c,i)=>{const s=series.rates[c],base=s[0]||1;
return {label:c,data:s.map(v=>+(v/base*100).toFixed(3)),borderColor:ccyColor(c,i),borderWidth:1.8,pointRadius:0,tension:.3};});
charts.perf=new Chart(ctx,{type:'line',data:{labels:series.dates.map(shortDate),datasets:ds},
options:{responsive:true,interaction:{mode:'index',intersect:false},
plugins:{legend:{labels:{boxWidth:11,padding:11}},
tooltip:{backgroundColor:C.surface2,borderColor:C.border,borderWidth:1,callbacks:{label:c=>` ${c.dataset.label}: ${c.parsed.y.toFixed(2)} (${(c.parsed.y-100>=0?'+':'')+(c.parsed.y-100).toFixed(2)}%)`}}},
scales:{x:{grid:{color:C.border},ticks:{maxTicksLimit:9}},y:{grid:{color:C.border},ticks:{callback:v=>v.toFixed(0)},title:{display:true,text:'index (start = 100)'}}}}});
}
function renderVolChart(series,curr){
const ctx=document.getElementById('vol-chart').getContext('2d');
if(charts.vol)charts.vol.destroy();
const rv=(p,w=10)=>{const o=[];for(let i=w;i<=p.length;i++){const sl=p.slice(i-w,i);
const r=sl.slice(1).map((v,j)=>Math.log(v/sl[j]));const m=r.reduce((a,b)=>a+b,0)/r.length;
const vv=r.reduce((a,b)=>a+(b-m)**2,0)/(r.length-1);o.push(+(Math.sqrt(vv)*Math.sqrt(252)*100).toFixed(3));}return o;};
const ds=curr.map((c,i)=>({label:c,data:rv(series.rates[c]),borderColor:ccyColor(c,i),borderWidth:1.8,pointRadius:0,tension:.3}));
charts.vol=new Chart(ctx,{type:'line',data:{labels:series.dates.slice(10).map(shortDate),datasets:ds},
options:{responsive:true,interaction:{mode:'index',intersect:false},
plugins:{legend:{labels:{boxWidth:11,padding:10}},
tooltip:{backgroundColor:C.surface2,borderColor:C.border,borderWidth:1,callbacks:{label:c=>` ${c.dataset.label}: ${c.parsed.y.toFixed(2)}%`}}},
scales:{x:{grid:{color:C.border},ticks:{maxTicksLimit:6}},y:{grid:{color:C.border},ticks:{callback:v=>v.toFixed(0)+'%'}}}}});
}
function renderSpark(id,data,color){
const cv=document.getElementById(id);if(!cv)return;const ctx=cv.getContext('2d');
if(charts[id])charts[id].destroy();
charts[id]=new Chart(ctx,{type:'line',data:{labels:data.map((_,i)=>i),datasets:[{data,borderColor:color,borderWidth:1.5,pointRadius:0,tension:.35,fill:true,
backgroundColor:()=>{const g=ctx.createLinearGradient(0,0,0,38);g.addColorStop(0,color+'33');g.addColorStop(1,'transparent');return g;}}]},
options:{responsive:false,plugins:{legend:{display:false},tooltip:{enabled:false}},scales:{x:{display:false},y:{display:false}}}});
}
/* ── STRESS ── */
async function loadStress(){ renderStress(await getJSON('/api/stress')); }
function renderStress(d){
const max=Math.max(...d.scenarios.map(s=>Math.abs(s.impact_usd)),.0001);
document.getElementById('stress-body').innerHTML=d.scenarios.map(s=>{
const w=Math.abs(s.impact_usd)/max*100,col=s.impact_usd>=0?C.green:C.red;
return `<tr><td><div style="font-weight:600;color:var(--text)">${s.name}</div><div class="card-sub" style="font-weight:400">${s.note}</div></td>
<td class="mono ${s.impact_usd>=0?'pos':'neg'}" style="font-weight:700">${signedUsd(s.impact_usd)}</td>
<td class="mono ${s.impact_usd>=0?'pos':'neg'}">${fmtPct(s.impact_pct,2)}</td>
<td><span class="stress-bar" style="width:${w}%;background:${col}"></span></td></tr>`;}).join('');
}
/* ── SCENARIO CALC (dynamic sliders) ── */
const PRESETS={
'COVID':{EUR:-3,SEK:-7.5,GBP:-10,CHF:-2,BRL:-15,MXN:-20,JPY:2,KRW:-6},
'USD surge':{EUR:-6.5,SEK:-9,GBP:-8,CHF:-3,BRL:2,MXN:-1,JPY:-10,KRW:-8},
'Risk-on':{EUR:4,SEK:6,GBP:4,CHF:-1,BRL:8,MXN:6,JPY:-3,KRW:5},
};
let scTimer=null, sliderEls={};
function buildSliders(curr){
if(Object.keys(sliderEls).length===curr.length && curr.every(c=>sliderEls[c]))return; // already built
const list=document.getElementById('slider-list');sliderEls={};
list.innerHTML=curr.map((c,i)=>`<div class="slider-row"><span class="slider-ccy" style="color:${ccyColor(c,i)}">${c}</span>
<input type="range" min="-15" max="15" step="0.5" value="0" data-ccy="${c}" id="sl-${c}"/>
<span class="slider-val" id="slv-${c}">0.0%</span></div>`).join('');
curr.forEach(c=>{const el=document.getElementById('sl-'+c);sliderEls[c]=el;el.oninput=updSliders;});
document.getElementById('preset-btns').innerHTML=
Object.keys(PRESETS).map(k=>`<button class="preset" data-preset="${k}">${k}</button>`).join('')+
`<button class="preset" data-preset="reset">Reset</button>`;
document.querySelectorAll('.preset').forEach(b=>b.onclick=()=>{
const p=b.dataset.preset==='reset'?{}:PRESETS[b.dataset.preset]||{};
curr.forEach(c=>{sliderEls[c].value=p[c]||0;});updSliders();});
}
function updSliders(){
CURRENCIES.forEach(c=>{const v=parseFloat(sliderEls[c].value),el=document.getElementById('slv-'+c);
el.textContent=(v>=0?'+':'')+v.toFixed(1)+'%';el.style.color=v>0?C.green:v<0?C.red:'var(--text)';});
clearTimeout(scTimer);scTimer=setTimeout(runScenario,140);
}
async function runScenario(){
const moves={};let any=false;
CURRENCIES.forEach(c=>{const v=parseFloat(sliderEls[c].value);moves[c]=v;if(v!==0)any=true;});
const out=document.getElementById('scenario-output');
if(!any){out.innerHTML='<div style="color:var(--muted);font-size:12px;padding:8px 0">Move a slider or pick a preset.</div>';return;}
const payload={moves}; if(customBook) payload.positions=customBook;
const d=await getJSON('/api/scenario',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
const rows=d.positions.filter(p=>p.impact_usd!==0).sort((a,b)=>a.impact_usd-b.impact_usd).map(p=>{
const cls=p.impact_usd>0?'pos':p.impact_usd<0?'neg':'dim';
return `<div class="scenario-row"><span class="s-ticker">${p.ticker} ${badge(p.currency)}</span><span class="s-impact mono ${cls}">${signedUsd(p.impact_usd)}</span></div>`;}).join('');
const tcls=d.total_usd>0?'pos':d.total_usd<0?'neg':'dim';
out.innerHTML=rows+`<div class="scenario-total"><span>Portfolio impact</span><span class="${tcls}">${signedUsd(d.total_usd)} <span class="card-sub" style="font-weight:600">(${fmtPct(d.pct_of_portfolio,2)})</span></span></div>`;
}
document.querySelectorAll('#horizon-seg button').forEach(b=>b.onclick=()=>{
document.querySelectorAll('#horizon-seg button').forEach(x=>x.classList.remove('active'));
b.classList.add('active');horizon=parseInt(b.dataset.h);loadRisk();});
setInterval(()=>{countdown=Math.max(0,countdown-1);document.getElementById('countdown').textContent=countdown>0?${countdown}s`:'· refreshing';},1000);
async function refresh(){
try{
if(customBook){
const d=await getJSON('/api/analyze',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({positions:customBook,horizon})});
renderRates(d.rates); renderPositions(d.positions); renderRisk(d.risk);
renderStress(d.stress); renderHistorical(d.historical);
} else {
await Promise.all([loadRates(),loadPositions(),loadRisk(),loadStress()]);
await loadHistorical(); /* sparklines depend on rate tiles existing */
}
} catch(e){console.error(e);document.getElementById('live-dot').className='live-dot error';
document.getElementById('last-updated').textContent='data error — retrying';}
countdown=REFRESH_MS/1000;
}
/* ── PORTFOLIO BUILDER ── */
const SUPPORTED_CCY=['EUR','SEK','GBP','CHF','NOK','DKK','JPY','BRL','MXN','KRW','AUD','CAD','CNY','INR','ZAR','SGD','PLN','TRY','NZD','HKD'];
const DEMO_BOOK=[
{ticker:'ASML',shares:40,avg_cost:620,currency:'EUR',exchange:'Amsterdam'},
{ticker:'LPKF',shares:300,avg_cost:9.5,currency:'EUR',exchange:'Frankfurt'},
{ticker:'SIVE',shares:200,avg_cost:58,currency:'SEK',exchange:'Stockholm'},
{ticker:'LOGN',shares:250,avg_cost:78,currency:'CHF',exchange:'Zurich'},
{ticker:'RR',shares:2000,avg_cost:4.2,currency:'GBP',exchange:'London'},
{ticker:'6758',shares:300,avg_cost:13000,currency:'JPY',exchange:'Tokyo'},
{ticker:'000660',shares:200,avg_cost:180000,currency:'KRW',exchange:'Seoul'},
{ticker:'VALE3',shares:1500,avg_cost:62,currency:'BRL',exchange:'Sao Paulo'},
{ticker:'AMXL',shares:3000,avg_cost:16,currency:'MXN',exchange:'Mexico City'},
];
const modal=document.getElementById('builder-modal');
function loadCustomBook(){ try{return JSON.parse(localStorage.getItem('fx_custom_book'))||null;}catch{return null;} }
function setBookPill(){ document.getElementById('book-pill').style.display=customBook?'inline-flex':'none'; }
function ccyOptions(sel){ return SUPPORTED_CCY.map(c=>`<option ${c===sel?'selected':''}>${c}</option>`).join(''); }
function builderRow(p={}){
return `<tr>
<td><input class="b-ticker" value="${p.ticker||''}" placeholder="ASML"/></td>
<td><input class="b-shares num" type="number" step="any" value="${p.shares??''}" placeholder="100"/></td>
<td><input class="b-avg num" type="number" step="any" value="${p.avg_cost??''}" placeholder="50.00"/></td>
<td><select class="b-ccy">${ccyOptions(p.currency||'EUR')}</select></td>
<td><input class="b-exch" value="${p.exchange||''}" placeholder="Exchange"/></td>
<td><button class="row-x" title="Remove">×</button></td></tr>`;
}
function fillBuilder(book){ document.getElementById('builder-rows').innerHTML=book.map(builderRow).join('')||builderRow(); bindRowX(); }
function bindRowX(){ document.querySelectorAll('#builder-rows .row-x').forEach(b=>b.onclick=()=>{b.closest('tr').remove();}); }
function readBuilder(){
return [...document.querySelectorAll('#builder-rows tr')].map(tr=>({
ticker:tr.querySelector('.b-ticker').value.trim(),
shares:parseFloat(tr.querySelector('.b-shares').value),
avg_cost:parseFloat(tr.querySelector('.b-avg').value),
currency:tr.querySelector('.b-ccy').value,
exchange:tr.querySelector('.b-exch').value.trim(),
})).filter(p=>p.ticker && p.shares>0 && p.avg_cost>0);
}
function openBuilder(){
const book=customBook||DEMO_BOOK;
fillBuilder(book);
document.getElementById('builder-err').textContent='';
modal.classList.add('open');
}
function closeBuilder(){ modal.classList.remove('open'); }
document.getElementById('edit-book-btn').onclick=openBuilder;
document.getElementById('modal-close').onclick=closeBuilder;
modal.onclick=e=>{ if(e.target===modal) closeBuilder(); };
document.getElementById('add-row').onclick=()=>{ document.getElementById('builder-rows').insertAdjacentHTML('beforeend',builderRow()); bindRowX(); };
document.getElementById('load-demo').onclick=()=>fillBuilder(DEMO_BOOK);
document.getElementById('reset-default').onclick=()=>{
customBook=null; localStorage.removeItem('fx_custom_book'); setBookPill(); closeBuilder(); refresh();
};
document.getElementById('book-reset').onclick=()=>{
customBook=null; localStorage.removeItem('fx_custom_book'); setBookPill(); refresh();
};
document.getElementById('apply-book').onclick=async()=>{
const book=readBuilder();
if(book.length===0){ document.getElementById('builder-err').textContent='Add at least one valid position (ticker, shares>0, cost>0).'; return; }
customBook=book; localStorage.setItem('fx_custom_book',JSON.stringify(book)); setBookPill(); closeBuilder();
sliderEls={}; /* force slider rebuild for the new currency set */
await refresh();
};
let customBook=loadCustomBook();
setBookPill();
async function init(){ await refresh(); setInterval(refresh,REFRESH_MS); }
init();
</script>
</body>
</html>