Files
personal-site/static/portfolio.html
T
2026-03-29 15:46:33 +02:00

497 lines
20 KiB
HTML

<!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" />
</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>
<div class="health-badge" id="healthBadge" title="API server status">
<span class="health-dot loading" id="healthDot"></span>
<span id="healthText">connecting to api…</span>
</div>
<p>
Test of API:
Source at
<a href="https://git.samantha42.xyz/samantha/FPandA-Engine"
style="color:var(--accent);text-decoration:none"
target="_blank">git.samantha42.xyz/samantha/FPandA-Engine</a>
</p>
</header>
<!-- Summary cards (populated dynamically from /positions/list) -->
<div class="cards">
<div class="card">
<p class="card-label">Total Weight</p>
<p class="card-value neutral" id="sumWeight"></p>1
</div>
<div class="card">
<p class="card-label">Positions</p>
<p class="card-value neutral" id="sumPositions"></p>
</div>
<div class="card">
<p class="card-label">Total Cost Basis</p>
<p class="card-value neutral" id="sumCostBasis"></p>
</div>
<div class="card">
<p class="card-label">Total Shares</p>
<p class="card-value neutral" id="sumShares"></p>
</div>
</div>
<!-- Positions table -->
<div class="section-header-row">
<p class="section-label" style="margin:0">// 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>Symbol</th>
<th>Currency</th>
<th>Shares</th>
<th>Cost Basis</th>
<th>Weight</th>
<th>Market Price</th>
</tr>
</thead>
<tbody id="positionsBody">
<tr class="skeleton-row"><td><div class="skeleton-cell" style="width:55%"></div></td><td><div class="skeleton-cell" style="width:40%"></div></td><td><div class="skeleton-cell" style="width:40%"></div></td><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:50%"></div></td></tr>
<tr class="skeleton-row"><td><div class="skeleton-cell" style="width:65%"></div></td><td><div class="skeleton-cell" style="width:35%"></div></td><td><div class="skeleton-cell" style="width:45%"></div></td><td><div class="skeleton-cell" style="width:55%"></div></td><td><div class="skeleton-cell" style="width:48%"></div></td><td><div class="skeleton-cell" style="width:52%"></div></td></tr>
<tr class="skeleton-row"><td><div class="skeleton-cell" style="width:60%"></div></td><td><div class="skeleton-cell" style="width:42%"></div></td><td><div class="skeleton-cell" style="width:38%"></div></td><td><div class="skeleton-cell" style="width:58%"></div></td><td><div class="skeleton-cell" style="width:44%"></div></td><td><div class="skeleton-cell" style="width:56%"></div></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>
<div class="trades-header-row">
<div class="trades-filter">
<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"></p>
<div class="trades-table-wrap">
<table class="trades-table">
<thead>
<tr>
<th>Symbol</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">
<tr><td colspan="7" class="trades-empty">open to load trades</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<footer>
<a href="mailto:samantha@samantha42.xyz">samantha@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">
<!-- API uses "symbol" + "currency_code", not tickerId -->
<div class="form-field full">
<label>SYMBOL</label>
<input type="text" id="f-symbol" placeholder="NOVO-B.CO" style="text-transform:uppercase"/>
</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 CODE</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>
<option value="4">4 — Bond</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>
<div class="toast" id="toast"></div>
<script>
const API = 'http://127.0.0.1:8080';
// ── Helpers ──────────────────────────────
const PRODUCT_LABELS = ['Stock', 'Opt Call', 'Opt Put', 'Currency', 'Bond'];
function productLabel(n) { return PRODUCT_LABELS[n] ?? '—'; }
// ── GET /health ──────────────────────────
async function checkHealth() {
const dot = document.getElementById('healthDot');
const text = document.getElementById('healthText');
try {
const res = await fetch(`${API}/health`);
const data = await res.json();
if (data.status === 'ok') {
dot.className = 'health-dot ok';
text.textContent = `api ok — uptime ${data.uptime ?? ''}`;
} else {
dot.className = 'health-dot error';
text.textContent = 'api degraded';
}
} catch {
dot.className = 'health-dot error';
text.textContent = 'api unreachable';
}
}
// ── GET /positions/list ──────────────────
let positions = [];
async function loadPositions() {
try {
const res = await fetch(`${API}/positions/list`);
if (!res.ok) throw new Error('status ' + res.status);
const data = await res.json();
positions = Array.isArray(data) ? data : (data ? [data] : []);
renderPositions();
updateSummaryCards();
} catch (err) {
console.warn('positions/list failed:', err);
document.getElementById('positionsBody').innerHTML =
`<tr><td colspan="6" class="trades-empty">could not load positions — ${err.message}</td></tr>`;
}
}
function renderPositions() {
const tbody = document.getElementById('positionsBody');
if (!positions.length) {
tbody.innerHTML = `<tr><td colspan="6" class="trades-empty">no positions found</td></tr>`;
return;
}
tbody.innerHTML = positions.map(p => {
const sym = p.Symbol ?? '—';
const ccy = p.CurrencyCode ?? '—';
const sh = p.Shares ?? '—';
const cb = typeof p.CostBasis === 'number'
? p.CostBasis.toLocaleString('da-DK', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
: '—';
const wt = typeof p.Weight === 'number'
? (p.Weight * 100).toFixed(2) + '%'
: '—';
const pct = typeof p.Weight === 'number' ? Math.min(p.Weight * 100, 100) : 0;
return `<tr data-symbol="${sym}">
<td><span class="ticker">${sym}</span><span class="currency-badge">${ccy}</span></td>
<td>${ccy}</td>
<td>${sh}</td>
<td>${cb}</td>
<td>
<div class="weight-cell">${wt}
<div class="weight-bar-track"><div class="weight-bar-fill" style="width:${pct}%"></div></div>
</div>
</td>
<td><span class="price-val" id="price-${sym}">—</span></td>
</tr>`;
}).join('');
}
function updateSummaryCards() {
const totalWeight = positions.reduce((s, p) => s + (p.Weight ?? 0), 0);
const totalCB = positions.reduce((s, p) => s + (p.CostBasis ?? 0), 0);
const totalShares = positions.reduce((s, p) => s + (p.Shares ?? 0), 0);
document.getElementById('sumWeight').textContent =
(totalWeight * 100).toFixed(2) + '%';
document.getElementById('sumPositions').textContent = positions.length;
document.getElementById('sumCostBasis').textContent =
totalCB.toLocaleString('da-DK', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
document.getElementById('sumShares').textContent = totalShares;
}
// ── Price fetching ────────────────────────
function setStatus(state, text) {
document.getElementById('priceDot').className = 'price-dot ' + state;
document.getElementById('priceStatusText').textContent = text;
}
function flashPrice(sym, newVal, oldVal) {
const el = document.getElementById('price-' + sym);
if (!el) return;
const cls = newVal > oldVal ? 'flash-up' : newVal < oldVal ? 'flash-down' : '';
el.textContent = newVal.toLocaleString('da-DK', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
if (cls) { el.classList.add(cls); setTimeout(() => el.classList.remove(cls), 1200); }
}
async function fetchHoldingPrices() {
if (!positions.length) return;
setStatus('loading', 'fetching prices…');
const prev = {};
positions.forEach(p => {
const el = document.getElementById('price-' + p.Symbol);
if (el) prev[p.Symbol] = parseFloat(el.textContent.replace(/\./g,'').replace(',','.')) || 0;
});
// Try backend proxy
try {
const res = await fetch('/api/prices', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tickers: positions.map(p => p.Symbol) }),
});
if (!res.ok) throw new Error('backend ' + res.status);
const data = await res.json();
const map = {};
data.forEach(d => { map[d.ticker] = d.close; });
positions.forEach(p => { if (map[p.Symbol] != null) flashPrice(p.Symbol, map[p.Symbol], prev[p.Symbol] || map[p.Symbol]); });
setStatus('live', 'updated ' + new Date().toLocaleTimeString('en-GB', { hour:'2-digit', minute:'2-digit' }));
return;
} catch { /* fallback */ }
// Fallback: direct Yahoo Finance
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(positions.map(async p => {
try {
const url = `https://corsproxy.io/?${encodeURIComponent(
`https://query1.finance.yahoo.com/v8/finance/chart/${p.Symbol}?period1=${start}&period2=${end}&interval=1d`
)}`;
const json = await (await fetch(url)).json();
const close = json?.chart?.result?.[0]?.indicators?.quote?.[0]?.close?.[0];
if (close != null) { flashPrice(p.Symbol, close, prev[p.Symbol] || 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');
}
// ── GET /trade/list ───────────────────────
let allTrades = [];
let activeFilter = 'all';
async function loadTrades() {
document.getElementById('tradesCount').textContent = 'loading…';
document.getElementById('tradesBody_rows').innerHTML =
`<tr><td colspan="7" class="trades-empty">loading…</td></tr>`;
try {
const res = await fetch(`${API}/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('trade/list failed:', 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) {
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 sym = t.Symbol ?? '—';
const ccy = t.CurrencyCode ?? '—';
const date = t.Date ? new Date(t.Date).toLocaleDateString('sv-SE') : '—';
const price = typeof t.Price === 'number'
? t.Price.toLocaleString('da-DK', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
: '—';
return `<tr>
<td><span class="trade-ticker">${sym}</span></td>
<td>${productLabel(t.Product)}</td>
<td>${ccy}</td>
<td>${date}</td>
<td class="${t.Type ? 'dir-sell' : 'dir-buy'}">${t.Type ? 'SELL' : 'BUY'}</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();
}
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);
if (!expanded && allTrades.length === 0) loadTrades();
}
// ── POST /trade/add ───────────────────────
function openAddTrade() {
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 symbol = document.getElementById('f-symbol').value.trim().toUpperCase();
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;
if (!symbol) return (errEl.textContent = 'symbol is required');
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 code 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');
// Payload matches POST /trade/add spec from wiki
const payload = {
symbol,
currency_code: currency,
shares,
product,
type,
price,
date: date.toISOString(),
};
btn.disabled = true;
btn.textContent = 'submitting…';
try {
const res = await fetch(`${API}/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();
} 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);
}
// ── Init ──────────────────────────────────
(async () => {
await Promise.all([checkHealth(), loadPositions()]);
fetchHoldingPrices();
})();
</script>
</body>
</html>