mirror of
https://github.com/Saauc/fx-risk-terminal.git
synced 2026-07-27 18:47:52 +00:00
a3817dc462
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>
667 lines
45 KiB
HTML
667 lines
45 KiB
HTML
<!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&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&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&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 & 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>
|