497 lines
20 KiB
HTML
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 · 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">© 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> |