Files
fx-risk-terminal/templates/dashboard.html
T

667 lines
45 KiB
HTML
Raw Normal View History

2026-06-16 22:12:00 -04:00
<!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>