Files
personal-site/static/portfolio.html
T
2026-03-23 16:08:42 +01:00

589 lines
19 KiB
HTML
Raw 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>Portfolio — Samantha Friis</title>
<link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@300;400;500&family=Syne:wght@400;700;800&display=swap" rel="stylesheet"/>
<link rel="stylesheet" href="styles.css">
<style>
/* ── Trades accordion ── */
.trades-section {
margin-top: 2.5rem;
}
.trades-toggle {
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
background: none;
border: none;
border-top: 1px solid var(--border, #2a2a3a);
border-bottom: 1px solid var(--border, #2a2a3a);
width: 100%;
padding: 0.85rem 0;
color: inherit;
font-family: 'DM Mono', monospace;
font-size: 0.75rem;
letter-spacing: 0.07em;
text-align: left;
user-select: none;
}
.trades-toggle:hover .toggle-label {
color: var(--accent, #f59e0b);
}
.toggle-label {
color: var(--muted, #475569);
transition: color 0.15s;
}
.toggle-label span {
color: var(--accent, #f59e0b);
}
.toggle-icon {
color: var(--muted, #475569);
font-size: 1rem;
transition: transform 0.25s ease;
line-height: 1;
}
.trades-toggle[aria-expanded="true"] .toggle-icon {
transform: rotate(180deg);
}
.trades-body {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.3s ease;
}
.trades-body.open {
grid-template-rows: 1fr;
}
.trades-inner {
overflow: hidden;
}
.trades-filter {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
padding: 1rem 0 0.75rem;
}
.filter-btn {
background: none;
border: 1px solid var(--border, #2a2a3a);
border-radius: 4px;
color: var(--muted, #475569);
font-family: 'DM Mono', monospace;
font-size: 0.7rem;
letter-spacing: 0.06em;
padding: 3px 10px;
cursor: pointer;
transition: all 0.15s;
}
.filter-btn:hover,
.filter-btn.active {
border-color: var(--accent, #f59e0b);
color: var(--accent, #f59e0b);
background: rgba(245,158,11,0.06);
}
.trades-table-wrap {
overflow-x: auto;
padding-bottom: 1.5rem;
}
.trades-table {
width: 100%;
border-collapse: collapse;
font-size: 0.78rem;
}
.trades-table thead tr {
border-bottom: 1px solid var(--border, #2a2a3a);
}
.trades-table th {
color: var(--muted, #475569);
font-weight: 400;
letter-spacing: 0.07em;
font-size: 0.68rem;
padding: 0.5rem 0.75rem;
text-align: left;
white-space: nowrap;
}
.trades-table td {
padding: 0.65rem 0.75rem;
border-bottom: 1px solid rgba(42,42,58,0.5);
white-space: nowrap;
color: var(--text, #e2e8f0);
}
.trades-table tbody tr:last-child td {
border-bottom: none;
}
.trades-table tbody tr:hover td {
background: rgba(255,255,255,0.02);
}
.trades-table tr.hidden-row {
display: none;
}
.dir-buy { color: #4ade80; }
.dir-sell { color: #f87171; }
.pnl-pos { color: #4ade80; }
.pnl-neg { color: #f87171; }
.pnl-zero{ color: var(--muted, #475569); }
.trade-code {
display: inline-block;
padding: 1px 6px;
border-radius: 3px;
font-size: 0.65rem;
background: rgba(42,42,58,0.8);
color: var(--muted, #475569);
letter-spacing: 0.05em;
}
.trade-ticker {
font-weight: 500;
color: #f1f5f9;
}
.trades-count {
color: var(--muted, #475569);
font-size: 0.68rem;
padding: 0 0 0.5rem;
}
/* ── Live price styles ── */
.price-cell {
position: relative;
}
.price-val {
transition: color 0.4s;
}
.price-val.loading {
color: var(--muted, #475569);
}
.price-val.flash-up {
color: #4ade80;
}
.price-val.flash-down {
color: #f87171;
}
.price-status {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.68rem;
color: var(--muted, #475569);
margin-left: 0.5rem;
vertical-align: middle;
}
.price-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--muted, #475569);
display: inline-block;
}
.price-dot.live { background: #4ade80; box-shadow: 0 0 4px #4ade80; }
.price-dot.error { background: #f87171; }
.price-dot.loading {
background: #f59e0b;
animation: pulse 1s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
.refresh-btn {
background: none;
border: 1px solid var(--border, #2a2a3a);
border-radius: 4px;
color: var(--muted, #475569);
font-family: 'DM Mono', monospace;
font-size: 0.68rem;
padding: 2px 8px;
cursor: pointer;
letter-spacing: 0.05em;
transition: all 0.15s;
}
.refresh-btn:hover {
border-color: var(--accent, #f59e0b);
color: var(--accent, #f59e0b);
}
.section-header-row {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.6rem;
}
</style>
</head>
<body>
<div class="container">
<header>
<a href="/" class="back">← back</a>
<p class="tag">// portfolio</p>
<h1>Investment<br/><span>Portfolio</span></h1>
<p class="header-sub">Equity positions &nbsp;·&nbsp; Personal research</p>
</header>
<!-- Summary cards -->
<div class="cards">
<div class="card">
<p class="card-label">Total Weight</p>
<p class="card-value neutral">100,10%</p>
</div>
<div class="card">
<p class="card-label">Weighted Return</p>
<p class="card-value up">+7,84%</p>
</div>
<div class="card">
<p class="card-label">Price Return (w)</p>
<p class="card-value up">+7,73%</p>
</div>
<div class="card">
<p class="card-label">Positions</p>
<p class="card-value neutral">5</p>
</div>
</div>
<!-- Invested table -->
<div class="section-header-row">
<p class="section-label" style="margin:0">// invested positions</p>
<span class="price-status" id="priceStatus">
<span class="price-dot loading" id="priceDot"></span>
<span id="priceStatusText">fetching prices…</span>
</span>
<button class="refresh-btn" onclick="fetchHoldingPrices()">↺ refresh</button>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Ticker</th>
<th>Market Price</th>
<th>Avg Price</th>
<th>Shares Out.</th>
<th>Mkt Cap (M)</th>
<th>Weight</th>
<th>Return Rate</th>
<th>Price Return</th>
<th>Rate × Weight</th>
<th>P.Return × Weight</th>
</tr>
</thead>
<tbody>
<tr data-ticker="MAERSK-B.CO">
<td><span class="ticker">MEARSK</span><span class="currency-badge">DKK</span></td>
<td class="price-cell"><span class="price-val" id="price-MAERSK-B.CO">15,270.00</span></td><td>12,414</td><td>15,224,309</td><td>232,475</td>
<td><div class="weight-cell">45.95%<div class="weight-bar-track"><div class="weight-bar-fill" style="width:46%"></div></div></div></td>
<td class="neutral">9%</td><td class="up">23.01%</td><td>4.17%</td><td class="up">10.57%</td>
</tr>
<tr data-ticker="DFDS.CO">
<td><span class="ticker">DFDS</span><span class="currency-badge">DKK</span></td>
<td class="price-cell"><span class="price-val" id="price-DFDS.CO">98.80</span></td><td>89.82</td><td>53,892,216</td><td>5,325</td>
<td><div class="weight-cell">10.43%<div class="weight-bar-track"><div class="weight-bar-fill" style="width:10%"></div></div></div></td>
<td class="neutral">8%</td><td class="up">10.00%</td><td>0.83%</td><td class="up">1.04%</td>
</tr>
<tr data-ticker="ORSTED.CO">
<td><span class="ticker">ORSTED</span><span class="currency-badge">DKK</span></td>
<td class="price-cell"><span class="price-val" id="price-ORSTED.CO">152.00</span></td><td>128.80</td><td>756,088,069</td><td>114,925</td>
<td><div class="weight-cell">9.17%<div class="weight-bar-track"><div class="weight-bar-fill" style="width:9%"></div></div></div></td>
<td class="neutral">3.25%</td><td class="up">18.01%</td><td>0.30%</td><td class="up">1.65%</td>
</tr>
<tr data-ticker="CSIQ">
<td><span class="ticker">CSIQ</span><span class="currency-badge">USD</span></td>
<td class="price-cell"><span class="price-val" id="price-CSIQ">20.74</span></td><td>20.53</td><td>67,620,463</td><td>1,402</td>
<td><div class="weight-cell">3.09%<div class="weight-bar-track"><div class="weight-bar-fill" style="width:3%"></div></div></div></td>
<td class="neutral">4.95%</td><td class="up">1.02%</td><td>0.15%</td><td class="up">0.03%</td>
</tr>
<tr data-ticker="NOVO-B.CO">
<td><span class="ticker">NOVO NORDISK</span><span class="currency-badge">DKK</span></td>
<td class="price-cell"><span class="price-val" id="price-NOVO-B.CO">251.00</span></td><td>305.00</td><td>4,442,064,180</td><td>1,114,958</td>
<td><div class="weight-cell">31.46%<div class="weight-bar-track"><div class="weight-bar-fill" style="width:31%"></div></div></div></td>
<td class="neutral">8%</td><td class="down">17.70%</td><td>2.38%</td><td class="down">5.57%</td>
</tr>
</tbody>
<tbody>
<tr><td colspan="10" class="watchlist-label">// watchlist — not invested</td></tr>
<tr class="watchlist-row">
<td><span class="ticker" style="color:var(--muted)">COST</span><span class="currency-badge">USD</span></td>
<td>978.00</td><td></td><td></td><td></td>
<td><div class="weight-cell"></div></td>
<td></td><td></td><td></td><td></td>
</tr>
</tbody>
</table>
</div>
<!-- ── Trades accordion ── -->
<div class="trades-section">
<button class="trades-toggle" aria-expanded="false" onclick="toggleTrades(this)">
<span class="toggle-label">// <span>trade history</span> — all executed orders</span>
<span class="toggle-icon"></span>
</button>
<div class="trades-body" id="tradesBody">
<div class="trades-inner">
<!-- Filter buttons -->
<div class="trades-filter">
<button class="filter-btn active" onclick="filterTrades('all', this)">All</button>
<button class="filter-btn" onclick="filterTrades('NOVOBc', this)">NOVOBc</button>
<button class="filter-btn" onclick="filterTrades('CSIQ', this)">CSIQ</button>
<button class="filter-btn" onclick="filterTrades('PYPL', this)">PYPL</button>
<button class="filter-btn" onclick="filterTrades('options', this)">Options</button>
</div>
<p class="trades-count" id="tradesCount">10 trades</p>
<div class="trades-table-wrap">
<table class="trades-table">
<thead>
<tr>
<th>Symbol</th>
<th>Asset</th>
<th>Ccy</th>
<th>Date / Time</th>
<th>Dir</th>
<th>Qty</th>
<th>Trade Price</th>
<th>Curr. Price</th>
<th>Proceeds</th>
<th>Comm/Fee</th>
<th>Basis</th>
<th>Realized P/L</th>
<th>MTM P/L</th>
<th>Code</th>
</tr>
</thead>
<tbody id="tradesBody_rows">
<!-- NOVOBc -->
<tr data-sym="NOVOBc">
<td><span class="trade-ticker">NOVOBc</span></td>
<td>Stocks</td><td>DKK</td>
<td>2026-02-24 03:05</td>
<td class="dir-buy">BUY</td>
<td>8</td><td>241.70</td><td>243.65</td>
<td>1,933.60</td><td>10.00</td><td>1,943.60</td>
<td class="pnl-zero">0.00</td><td class="pnl-pos">+15.60</td>
<td><span class="trade-code">O</span></td>
</tr>
<tr data-sym="NOVOBc">
<td><span class="trade-ticker">NOVOBc</span></td>
<td>Stocks</td><td>DKK</td>
<td>2026-02-25 07:10</td>
<td class="dir-buy">BUY</td>
<td>3</td><td>239.50</td><td>238.40</td>
<td>718.50</td><td>10.00</td><td>728.50</td>
<td class="pnl-zero">0.00</td><td class="pnl-neg">3.30</td>
<td><span class="trade-code">O</span></td>
</tr>
<!-- CSIQ -->
<tr data-sym="CSIQ">
<td><span class="trade-ticker">CSIQ</span></td>
<td>Stocks</td><td>USD</td>
<td>2026-01-20 14:33</td>
<td class="dir-buy">BUY</td>
<td>8</td><td>20.48</td><td>20.51</td>
<td>163.84</td><td>0.38</td><td>164.22</td>
<td class="pnl-zero">0.00</td><td class="pnl-pos">+0.24</td>
<td><span class="trade-code">O</span></td>
</tr>
<!-- PYPL -->
<tr data-sym="PYPL">
<td><span class="trade-ticker">PYPL</span></td>
<td>Stocks</td><td>USD</td>
<td>2026-01-20 14:31</td>
<td class="dir-sell">SELL</td>
<td>3</td><td>55.18</td><td>55.08</td>
<td>165.53</td><td>0.35</td><td>187.91</td>
<td class="pnl-neg">22.73</td><td class="pnl-pos">+0.29</td>
<td><span class="trade-code">C</span></td>
</tr>
<!-- Options -->
<tr data-sym="options">
<td><span class="trade-ticker">RGTI 15JAN27 5.5 P</span></td>
<td>Options</td><td>USD</td>
<td>2026-02-24 09:34</td>
<td class="dir-buy">BUY</td>
<td>1</td><td>0.53</td><td>0.5271</td>
<td>53.00</td><td>1.05</td><td>54.05</td>
<td class="pnl-zero">0.00</td><td class="pnl-neg">0.29</td>
<td><span class="trade-code">O</span></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<footer>
<a href="mailto:me@samantha42.xyz">me@samantha42.xyz</a>
<p class="footer-copy">&copy; 2026 — All rights reserved</p>
</footer>
</div>
<script>
// ── Holdings price fetch ──
// Calls the Go backend's /api/prices endpoint with a list of tickers.
// Falls back to FetchEOD-style Yahoo Finance if the backend isn't available.
const HOLDINGS = [
{ ticker: 'MAERSK-B.CO', label: 'MEARSK' },
{ ticker: 'DFDS.CO', label: 'DFDS' },
{ ticker: 'ORSTED.CO', label: 'ORSTED' },
{ ticker: 'CSIQ', label: 'CSIQ' },
{ ticker: 'NOVO-B.CO', label: 'NOVO NORDISK' },
];
function setStatus(state, text) {
const dot = document.getElementById('priceDot');
const label = document.getElementById('priceStatusText');
dot.className = 'price-dot ' + state;
label.textContent = text;
}
function flashPrice(id, newVal, oldVal) {
const el = document.getElementById('price-' + id);
if (!el) return;
const direction = newVal > oldVal ? 'flash-up' : newVal < oldVal ? 'flash-down' : '';
el.textContent = newVal.toLocaleString('da-DK', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
if (direction) {
el.classList.add(direction);
setTimeout(() => el.classList.remove(direction), 1200);
}
}
async function fetchHoldingPrices() {
setStatus('loading', 'fetching prices…');
// Read current displayed values before overwriting
const prev = {};
HOLDINGS.forEach(h => {
const el = document.getElementById('price-' + h.ticker);
if (el) prev[h.ticker] = parseFloat(el.textContent.replace(/\./g, '').replace(',', '.')) || 0;
});
try {
// POST to Go backend — expects JSON array: [{ticker, close}, ...]
const res = await fetch('/api/prices', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tickers: HOLDINGS.map(h => h.ticker) }),
});
if (!res.ok) throw new Error('backend returned ' + res.status);
const data = await res.json(); // [{ ticker, close, date, currency }, ...]
const map = {};
data.forEach(d => { map[d.ticker] = d.close; });
HOLDINGS.forEach(h => {
const close = map[h.ticker];
if (close != null) flashPrice(h.ticker, close, prev[h.ticker] || close);
});
const now = new Date().toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });
setStatus('live', 'updated ' + now);
} catch (err) {
console.warn('Backend unavailable, trying Yahoo Finance directly…', err);
await fetchDirectYahoo(prev);
}
}
// Direct Yahoo Finance fallback (CORS proxy) — same approach as the React artifact
async function fetchDirectYahoo(prev) {
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
// Roll back past weekends
while (yesterday.getDay() === 0 || yesterday.getDay() === 6) {
yesterday.setDate(yesterday.getDate() - 1);
}
const start = Math.floor(yesterday.setHours(0,0,0,0) / 1000);
const end = start + 86400;
let ok = 0, fail = 0;
await Promise.allSettled(HOLDINGS.map(async h => {
try {
const url = `https://corsproxy.io/?${encodeURIComponent(
`https://query1.finance.yahoo.com/v8/finance/chart/${h.ticker}?period1=${start}&period2=${end}&interval=1d`
)}`;
const res = await fetch(url);
const json = await res.json();
const close = json?.chart?.result?.[0]?.indicators?.quote?.[0]?.close?.[0];
if (close) {
flashPrice(h.ticker, close, prev[h.ticker] || close);
ok++;
} else {
fail++;
}
} catch { fail++; }
}));
const now = new Date().toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });
if (fail === 0) {
setStatus('live', 'updated ' + now + ' (direct)');
} else if (ok > 0) {
setStatus('live', `${ok} ok, ${fail} failed — ${now}`);
} else {
setStatus('error', 'price fetch failed');
}
}
// Auto-fetch on page load
document.addEventListener('DOMContentLoaded', fetchHoldingPrices);
// ── Trades accordion ──
function toggleTrades(btn) {
const body = document.getElementById('tradesBody');
const expanded = btn.getAttribute('aria-expanded') === 'true';
btn.setAttribute('aria-expanded', String(!expanded));
body.classList.toggle('open', !expanded);
}
function filterTrades(sym, btn) {
// Update active button
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
const rows = document.querySelectorAll('#tradesBody_rows tr');
let visible = 0;
rows.forEach(row => {
const match = sym === 'all' || row.dataset.sym === sym;
row.classList.toggle('hidden-row', !match);
if (match) visible++;
});
document.getElementById('tradesCount').textContent =
visible + ' trade' + (visible !== 1 ? 's' : '') + (sym !== 'all' ? ' — ' + sym : '');
}
</script>
</body>
</html>