Files
personal-site/static/portfolio.html
T
2026-03-29 07:41:08 +02:00

697 lines
26 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>
/* ── Add Trade Modal ── */
.modal-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.75);
backdrop-filter: blur(4px);
z-index: 100;
align-items: center;
justify-content: center;
}
.modal-overlay.open { display: flex; }
.modal {
background: var(--bg, #0d0d0d);
border: 1px solid var(--border, #2a2a2a);
width: min(520px, 94vw);
padding: 2rem;
position: relative;
animation: modalIn .2s ease;
}
@keyframes modalIn {
from { opacity:0; transform: translateY(12px); }
to { opacity:1; transform: translateY(0); }
}
.modal-title {
font-family: 'DM Mono', monospace;
font-size: .75rem;
color: var(--muted, #555);
letter-spacing: .08em;
margin-bottom: 1.4rem;
}
.modal-title span { color: var(--fg, #e8e8e8); }
.modal-close {
position: absolute;
top: 1rem; right: 1rem;
background: none;
border: none;
color: var(--muted, #555);
font-size: 1.1rem;
cursor: pointer;
line-height: 1;
padding: .2rem .4rem;
transition: color .15s;
}
.modal-close:hover { color: var(--fg, #e8e8e8); }
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: .8rem 1.2rem;
}
.form-grid .full { grid-column: 1 / -1; }
.form-field label {
display: block;
font-family: 'DM Mono', monospace;
font-size: .68rem;
color: var(--muted, #555);
letter-spacing: .06em;
margin-bottom: .3rem;
}
.form-field input,
.form-field select {
width: 100%;
background: #141414;
border: 1px solid #2a2a2a;
color: var(--fg, #e8e8e8);
font-family: 'DM Mono', monospace;
font-size: .82rem;
padding: .5rem .7rem;
outline: none;
transition: border-color .15s;
box-sizing: border-box;
-webkit-appearance: none;
appearance: none;
}
.form-field input:focus,
.form-field select:focus { border-color: #555; }
.form-field select option { background: #1a1a1a; }
.form-actions {
display: flex;
justify-content: flex-end;
gap: .7rem;
margin-top: 1.4rem;
}
.btn-cancel {
background: none;
border: 1px solid #2a2a2a;
color: var(--muted, #555);
font-family: 'DM Mono', monospace;
font-size: .78rem;
padding: .5rem 1.1rem;
cursor: pointer;
transition: border-color .15s, color .15s;
}
.btn-cancel:hover { border-color: #555; color: var(--fg, #e8e8e8); }
.btn-submit {
background: var(--fg, #e8e8e8);
border: none;
color: #0d0d0d;
font-family: 'DM Mono', monospace;
font-size: .78rem;
font-weight: 500;
padding: .5rem 1.3rem;
cursor: pointer;
transition: opacity .15s;
}
.btn-submit:hover { opacity: .85; }
.btn-submit:disabled { opacity: .4; cursor: not-allowed; }
.form-error {
font-family: 'DM Mono', monospace;
font-size: .72rem;
color: #e05555;
margin-top: .9rem;
min-height: 1rem;
}
/* ── Add Trade trigger button ── */
.add-trade-btn {
background: none;
border: 1px solid #2a2a2a;
color: var(--muted, #555);
font-family: 'DM Mono', monospace;
font-size: .72rem;
letter-spacing: .06em;
padding: .35rem .8rem;
cursor: pointer;
transition: border-color .15s, color .15s;
margin-left: auto;
}
.add-trade-btn:hover { border-color: #888; color: var(--fg, #e8e8e8); }
/* ── Loading skeleton rows ── */
.skeleton-row td {
padding: .6rem 0;
}
.skeleton-cell {
height: .7rem;
background: linear-gradient(90deg, #1c1c1c 25%, #252525 50%, #1c1c1c 75%);
background-size: 200% 100%;
animation: shimmer 1.4s infinite;
border-radius: 2px;
width: 70%;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.trades-empty {
font-family: 'DM Mono', monospace;
font-size: .78rem;
color: var(--muted, #555);
padding: 1.4rem 0;
text-align: center;
}
/* trades section header row flex */
.trades-header-row {
display: flex;
align-items: center;
gap: .7rem;
padding: .9rem 1rem .6rem;
border-bottom: 1px solid #1e1e1e;
}
/* toast */
.toast {
position: fixed;
bottom: 1.8rem;
right: 1.8rem;
background: #1c1c1c;
border: 1px solid #2e2e2e;
color: var(--fg, #e8e8e8);
font-family: 'DM Mono', monospace;
font-size: .78rem;
padding: .7rem 1.1rem;
z-index: 200;
opacity: 0;
transform: translateY(8px);
transition: opacity .2s, transform .2s;
pointer-events: none;
}
.toast.show { opacity: 1; transform: translateY(0); }
.toast.success { border-color: #3a6e3a; color: #7ecf7e; }
.toast.error { border-color: #6e2e2e; color: #cf7e7e; }
</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 + Add row -->
<div class="trades-header-row">
<div class="trades-filter" style="margin:0;border:none;padding:0">
<button class="filter-btn active" onclick="filterTrades('all', this)">All</button>
<button class="filter-btn" onclick="filterTrades('stock', this)">Stocks</button>
<button class="filter-btn" onclick="filterTrades('option', this)">Options</button>
<button class="filter-btn" onclick="filterTrades('buy', this)">Buy</button>
<button class="filter-btn" onclick="filterTrades('sell', this)">Sell</button>
</div>
<button class="add-trade-btn" onclick="openAddTrade()">+ add trade</button>
</div>
<p class="trades-count" id="tradesCount">loading…</p>
<div class="trades-table-wrap">
<table class="trades-table">
<thead>
<tr>
<th>Ticker</th>
<th>Asset</th>
<th>Ccy</th>
<th>Date</th>
<th>Dir</th>
<th>Qty</th>
<th>Price</th>
</tr>
</thead>
<tbody id="tradesBody_rows">
<!-- skeleton -->
<tr class="skeleton-row">
<td><div class="skeleton-cell" style="width:60%"></div></td>
<td><div class="skeleton-cell" style="width:50%"></div></td>
<td><div class="skeleton-cell" style="width:40%"></div></td>
<td><div class="skeleton-cell" style="width:80%"></div></td>
<td><div class="skeleton-cell" style="width:35%"></div></td>
<td><div class="skeleton-cell" style="width:30%"></div></td>
<td><div class="skeleton-cell" style="width:55%"></div></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>
<!-- ── Add Trade Modal ── -->
<div class="modal-overlay" id="addTradeModal" onclick="handleOverlayClick(event)">
<div class="modal">
<p class="modal-title">// <span>add trade</span> — new executed order</p>
<button class="modal-close" onclick="closeAddTrade()"></button>
<div class="form-grid">
<div class="form-field">
<label>TICKER ID</label>
<input type="number" id="f-tickerId" min="1" placeholder="1" />
</div>
<div class="form-field">
<label>SHARES</label>
<input type="number" id="f-shares" min="1" placeholder="10" />
</div>
<div class="form-field">
<label>PRICE</label>
<input type="number" id="f-price" step="0.01" placeholder="251.00" />
</div>
<div class="form-field">
<label>CURRENCY</label>
<input type="text" id="f-currency" placeholder="DKK" maxlength="8" style="text-transform:uppercase"/>
</div>
<div class="form-field">
<label>PRODUCT</label>
<select id="f-product">
<option value="0">0 — Stock</option>
<option value="1">1 — Option Call</option>
<option value="2">2 — Option Put</option>
<option value="3">3 — Currency</option>
</select>
</div>
<div class="form-field">
<label>DIRECTION</label>
<select id="f-type">
<option value="false">Buy</option>
<option value="true">Sell</option>
</select>
</div>
<div class="form-field full">
<label>TRADE DATE</label>
<input type="datetime-local" id="f-date" />
</div>
</div>
<div class="form-error" id="formError"></div>
<div class="form-actions">
<button class="btn-cancel" onclick="closeAddTrade()">cancel</button>
<button class="btn-submit" id="submitBtn" onclick="submitTrade()">submit trade</button>
</div>
</div>
</div>
<!-- Toast -->
<div class="toast" id="toast"></div>
<script>
// ─────────────────────────────────────────
// Product + Type label helpers
// ─────────────────────────────────────────
const PRODUCT_LABELS = ['Stock', 'Opt Call', 'Opt Put', 'Currency'];
function productLabel(n) { return PRODUCT_LABELS[n] ?? '—'; }
function typeLabel(v) { return v ? 'SELL' : 'BUY'; }
// ─────────────────────────────────────────
// Trade list — GET /trade/list
// ─────────────────────────────────────────
let allTrades = [];
let activeFilter = 'all';
async function loadTrades() {
try {
const res = 'http://localhost:8080/trade/list';
if (!res.ok) throw new Error('status ' + res.status);
const data = await res.json();
allTrades = Array.isArray(data) ? data : [];
} catch (err) {
console.warn('Could not load trades:', err);
allTrades = [];
}
renderTrades();
}
function renderTrades() {
const tbody = document.getElementById('tradesBody_rows');
const countEl = document.getElementById('tradesCount');
const filtered = allTrades.filter(t => {
if (activeFilter === 'all') return true;
if (activeFilter === 'stock') return t.Product === 0;
if (activeFilter === 'option') return t.Product === 1 || t.Product === 2;
if (activeFilter === 'buy') return t.Type === false;
if (activeFilter === 'sell') return t.Type === true;
return true;
});
if (filtered.length === 0) {
tbody.innerHTML = `<tr><td colspan="7" class="trades-empty">${allTrades.length === 0 ? 'no trades found' : 'no trades match filter'}</td></tr>`;
countEl.textContent = '0 trades';
return;
}
countEl.textContent = filtered.length + ' trade' + (filtered.length !== 1 ? 's' : '')
+ (activeFilter !== 'all' ? ' — ' + activeFilter : '');
tbody.innerHTML = filtered.map(t => {
const ticker = t.Ticker?.Name ?? '—';
const product = productLabel(t.Product);
const ccy = t.Currency?.Code ?? '—';
const date = t.Date ? new Date(t.Date).toLocaleDateString('sv-SE') : '—';
const dir = t.Type;
const dirCls = dir ? 'dir-sell' : 'dir-buy';
const dirLbl = dir ? 'SELL' : 'BUY';
const price = typeof t.Price === 'number'
? t.Price.toLocaleString('da-DK', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
: '—';
return `<tr>
<td><span class="trade-ticker">${ticker}</span></td>
<td>${product}</td>
<td>${ccy}</td>
<td>${date}</td>
<td class="${dirCls}">${dirLbl}</td>
<td>${t.Shares ?? '—'}</td>
<td>${price}</td>
</tr>`;
}).join('');
}
function filterTrades(sym, btn) {
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
activeFilter = sym;
renderTrades();
}
// ─────────────────────────────────────────
// Add Trade Modal
// ─────────────────────────────────────────
function openAddTrade() {
// Default date to now
const now = new Date();
now.setMinutes(now.getMinutes() - now.getTimezoneOffset());
document.getElementById('f-date').value = now.toISOString().slice(0, 16);
document.getElementById('formError').textContent = '';
document.getElementById('addTradeModal').classList.add('open');
}
function closeAddTrade() {
document.getElementById('addTradeModal').classList.remove('open');
}
function handleOverlayClick(e) {
if (e.target === document.getElementById('addTradeModal')) closeAddTrade();
}
async function submitTrade() {
const errEl = document.getElementById('formError');
const btn = document.getElementById('submitBtn');
errEl.textContent = '';
const tickerId = parseInt(document.getElementById('f-tickerId').value);
const shares = parseInt(document.getElementById('f-shares').value);
const price = parseFloat(document.getElementById('f-price').value);
const currency = document.getElementById('f-currency').value.trim().toUpperCase();
const product = parseInt(document.getElementById('f-product').value);
const type = document.getElementById('f-type').value === 'true';
const dateVal = document.getElementById('f-date').value;
// Client-side validation
if (!tickerId || tickerId < 1) return errEl.textContent = 'ticker id must be a positive integer';
if (!shares || shares < 1) return errEl.textContent = 'shares must be a positive integer';
if (!price || price <= 0) return errEl.textContent = 'price must be a positive number';
if (!currency) return errEl.textContent = 'currency is required';
if (!dateVal) return errEl.textContent = 'trade date is required';
const date = new Date(dateVal);
if (date > new Date()) return errEl.textContent = 'date cannot be in the future';
const payload = {
TickerId: tickerId,
Shares: shares,
Product: product,
Type: type,
Price: price,
Currency: currency,
Date: date.toISOString(),
};
btn.disabled = true;
btn.textContent = 'submitting…';
try {
const res = await fetch('http://localhost:8080/trade/add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) {
const msg = await res.text().catch(() => 'unknown error');
throw new Error(msg || 'server returned ' + res.status);
}
closeAddTrade();
showToast('trade added successfully', 'success');
await loadTrades(); // refresh list
} catch (err) {
errEl.textContent = err.message || 'failed to submit trade';
} finally {
btn.disabled = false;
btn.textContent = 'submit trade';
}
}
// ─────────────────────────────────────────
// Toast
// ─────────────────────────────────────────
function showToast(msg, type = '') {
const el = document.getElementById('toast');
el.textContent = msg;
el.className = 'toast ' + type + ' show';
clearTimeout(el._t);
el._t = setTimeout(() => { el.classList.remove('show'); }, 3000);
}
// ─────────────────────────────────────────
// 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);
// Load on first open
if (!expanded && allTrades.length === 0) loadTrades();
}
// ─────────────────────────────────────────
// Holdings price fetch (unchanged)
// ─────────────────────────────────────────
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) {
document.getElementById('priceDot').className = 'price-dot ' + state;
document.getElementById('priceStatusText').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…');
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 {
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();
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);
}
}
async function fetchDirectYahoo(prev) {
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
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');
}
document.addEventListener('DOMContentLoaded', () => {
fetchHoldingPrices();
// Eagerly load trades so accordion is instant on first open
loadTrades();
});
</script>
</body>
</html>