/* Cassandra — browser-side portfolio (Phase G). * * The server never persists holdings. The pie lives in this browser's * localStorage under `cassandra.pie`; the server only knows what * tickers the universe contains (anonymously). This module: * * 1. On dashboard load, hydrates the portfolio panel from localStorage. * 2. Fetches /api/universe (gzipped, same for every user) and computes * P/L locally. * 3. Refreshes prices every 60s. * 4. On /upload submission, POSTs to /api/portfolio/parse, stashes the * returned pie in localStorage, redirects to the dashboard. * 5. The "analyze" button POSTs the pie + prices to /api/analyze and * renders the returned commentary inline. */ (function () { 'use strict'; const STORAGE_KEY = 'cassandra.pie'; const UNIVERSE_REFRESH_MS = 60_000; // --- localStorage ------------------------------------------------------ function loadPie() { try { const raw = localStorage.getItem(STORAGE_KEY); return raw ? JSON.parse(raw) : null; } catch (e) { console.warn('cassandra.pie: corrupt localStorage, clearing', e); localStorage.removeItem(STORAGE_KEY); return null; } } function savePie(pie) { pie.imported_at = new Date().toISOString(); localStorage.setItem(STORAGE_KEY, JSON.stringify(pie)); } function clearPie() { localStorage.removeItem(STORAGE_KEY); } // Cache the AI analysis inside the pie so it survives auto-refreshes // and full page reloads. Cleared when the pie itself is forgotten or // replaced. We re-fetch on demand by clicking "Regenerate". function saveAnalysis(analysis) { const pie = loadPie(); if (!pie) return; pie.analysis = analysis; localStorage.setItem(STORAGE_KEY, JSON.stringify(pie)); } // --- Universe ---------------------------------------------------------- let universeCache = null; let universeFetchedAt = 0; async function fetchUniverse() { const r = await fetch('/api/universe', { headers: { 'Accept': 'application/json' }, credentials: 'same-origin', }); if (!r.ok) throw new Error('universe: HTTP ' + r.status); universeCache = await r.json(); universeFetchedAt = Date.now(); return universeCache; } function priceFor(ticker) { if (!universeCache || !universeCache.tickers) return null; return universeCache.tickers[ticker] || null; } // --- FX conversion ----------------------------------------------------- // fx[CCY] = units of CCY per 1 USD (USD itself is 1.0). // To convert X-currency value to Y-currency: value_y = value_x * (fx[Y] / fx[X]). function convertCurrency(value, fromCcy, toCcy, fx) { if (value == null || !isFinite(value)) return null; if (!fromCcy || !toCcy || fromCcy === toCcy) return value; if (!fx || !fx[fromCcy] || !fx[toCcy]) return null; // missing rate return value * (fx[toCcy] / fx[fromCcy]); } // --- P/L computation --------------------------------------------------- function enrichPosition(p, base, fx) { // T212 reports `invested value` (and therefore avg_cost) in the pie's // base currency — so the avg row is ALREADY in base. The current // price from Yahoo is in the ticker's local currency and must be // converted before subtracting. const q = priceFor(p.yahoo_ticker); const priceLocal = q ? q.p : null; const priceCcy = (q && q.c) || p.currency || null; const priceBase = convertCurrency(priceLocal, priceCcy, base, fx); const value = (priceBase != null && p.qty != null) ? priceBase * p.qty : null; const invested = (p.avg_cost != null && p.qty != null) ? p.avg_cost * p.qty : null; const ppl = (value != null && invested != null) ? value - invested : null; const ppl_pct = (value != null && invested) ? (value / invested - 1) * 100 : null; const d1 = q && q.d ? q.d['1d'] : null; return Object.assign({}, p, { _current_price_local: priceLocal, _current_price_base: priceBase, _currency: priceCcy, // for the currency-mix pills _base_currency: base, _value: value, _invested: invested, _ppl: ppl, _ppl_pct: ppl_pct, _change_1d: d1, _fx_missing: priceLocal != null && priceBase == null, }); } function aggregate(positions) { let totalValue = 0, totalInvested = 0, missingPrice = 0; const byCcy = {}; for (const r of positions) { if (r._value != null) { totalValue += r._value; if (r._currency) byCcy[r._currency] = (byCcy[r._currency] || 0) + r._value; } else { missingPrice++; } if (r._invested != null) totalInvested += r._invested; } return { n_positions: positions.length, missing_price: missingPrice, total_value: totalValue || null, total_invested: totalInvested || null, total_ppl: (totalValue && totalInvested) ? totalValue - totalInvested : null, total_ppl_pct: (totalValue && totalInvested) ? (totalValue / totalInvested - 1) * 100 : null, by_currency: byCcy, }; } // --- Rendering --------------------------------------------------------- function fmt(n, opts) { if (n == null || !isFinite(n)) return '—'; const o = Object.assign({ minimumFractionDigits: 2, maximumFractionDigits: 2 }, opts || {}); return Number(n).toLocaleString(undefined, o); } function signed(n) { if (n == null || !isFinite(n)) return '—'; return (n >= 0 ? '+' : '') + fmt(n); } function pct(n) { if (n == null || !isFinite(n)) return '—'; return (n >= 0 ? '+' : '') + Number(n).toFixed(2) + '%'; } function cls(n) { if (n == null) return 'neu'; return n >= 0 ? 'pos' : 'neg'; } function esc(s) { if (s == null) return ''; return String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); } function renderEmpty(mount) { mount.innerHTML = '
' + 'No portfolio loaded in this browser. ' + 'Import a T212 CSV →' + '
'; } function renderPanel(mount, pie, enriched, agg) { const ccyPills = Object.keys(agg.by_currency) .sort((a, b) => agg.by_currency[b] - agg.by_currency[a]) .map(c => { const share = agg.total_value ? (agg.by_currency[c] / agg.total_value * 100) : 0; return '' + esc(c) + ' ' + share.toFixed(0) + '%'; }) .join(' '); const rows = enriched.map(p => { // 'Last' shows the local-currency price (matches Yahoo + T212 display). // P/L column is in the pie's base currency after FX conversion. const lastDisplay = p._current_price_local != null ? fmt(p._current_price_local) + (p._currency && p._currency !== pie.base_currency ? ' ' + esc(p._currency) + '' : '') : '—'; const fxBadge = p._fx_missing ? ' ' + esc(p._currency || '?') + '' : ''; return '' + '' + esc(p.yahoo_ticker) + '' + '' + esc(p.name || '') + '' + '' + fmt(p.qty, { maximumFractionDigits: 6 }) + '' + '' + fmt(p.avg_cost) + '' + '' + lastDisplay + fxBadge + '' + '' + signed(p._ppl) + '' + '' + pct(p._ppl_pct) + '' + ''; }).join(''); const importedAt = pie.imported_at ? new Date(pie.imported_at).toISOString().replace('T', ' ').slice(0, 16) + ' UTC' : '—'; // Prices' as-of timestamp comes from the universe response; that's // the moment the server fetched the quotes this panel just used. // Falls back to "now" if universeCache hasn't populated yet. const pricesAsOf = (universeCache && universeCache.as_of) ? new Date(universeCache.as_of).toISOString().replace('T', ' ').slice(0, 19) + ' UTC' : new Date().toISOString().replace('T', ' ').slice(0, 19) + ' UTC'; const nameTooltip = 'Imported ' + importedAt + ' — kept in this browser only'; const missingNote = agg.missing_price > 0 ? '
' + agg.missing_price + ' position(s) have no live price — universe may be catching up.
' : ''; mount.innerHTML = '
' + '
' + '' + esc(pie.pie_name || 'Portfolio') + '' + '' + 'prices ' + esc(pricesAsOf) + '' + '
' + '
' + '
Total
' + '
' + fmt(agg.total_value) + ' ' + esc(pie.base_currency || '') + '
' + '
Invested
' + '
' + fmt(agg.total_invested) + '
' + '
Unrealised P/L
' + '
' + signed(agg.total_ppl) + (agg.total_ppl_pct != null ? ' (' + pct(agg.total_ppl_pct) + ')' : '') + '
' + '
Positions
' + '
' + agg.n_positions + '
' + '
' + '
Currency mix
' + '
' + (ccyPills || '—') + '
' + '
' + '
' + missingNote + '' + '' + '' + '' + '' + '' + '' + '' + rows + '' + '
TickerNameQtyAvgLastP/L%
' + '
' + '' + '' + '
' + ''; document.getElementById('pf-analyze').addEventListener('click', () => runAnalysis(pie, enriched)); document.getElementById('pf-forget').addEventListener('click', () => { if (confirm('Remove the saved pie from this browser? The server holds nothing — this is local.')) { clearPie(); mountAndRender(); } }); // Re-hydrate any cached AI analysis so the 60s auto-refresh doesn't // wipe it. Collapsed by default on hydration so the panel stays // compact — click the header to expand. if (pie.analysis && pie.analysis.content) { showAnalysis(pie.analysis, { open: false }); } } function showAnalysis(analysis, opts) { const out = document.getElementById('pf-analysis'); if (!out) return; const openAttr = (opts && opts.open) ? ' open' : ''; out.hidden = false; out.innerHTML = '
' + '' + '' + 'AI analysis' + '' + '' + esc((analysis.generated_at || '').slice(0, 19).replace('T', ' ')) + ' UTC' + '' + '
' + esc(analysis.content) + '
' + '
'; } async function runAnalysis(pie, enriched) { const out = document.getElementById('pf-analysis'); const btn = document.getElementById('pf-analyze'); out.hidden = false; out.innerHTML = '
generating…
'; btn.disabled = true; // Build the prices payload from the universe cache so the server // doesn't have to re-fetch. const prices = {}; if (universeCache && universeCache.tickers) { for (const p of pie.positions) { const q = universeCache.tickers[p.yahoo_ticker]; if (q) prices[p.yahoo_ticker] = q; } } try { const r = await fetch('/api/analyze', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ positions: pie.positions, prices: prices, base_currency: pie.base_currency || 'GBP', }), }); const data = await r.json(); if (!r.ok) { out.innerHTML = '
' + esc(data.detail || ('HTTP ' + r.status)) + '
'; return; } // Persist before rendering so auto-refresh can re-hydrate. saveAnalysis(data); showAnalysis(data, { open: true }); } catch (e) { out.innerHTML = '
' + esc(e.message) + '
'; } finally { btn.disabled = false; } } // --- Mount / refresh --------------------------------------------------- async function mountAndRender() { const mount = document.getElementById('pf-mount'); if (!mount) return; const pie = loadPie(); if (!pie || !pie.positions || !pie.positions.length) { renderEmpty(mount); return; } try { if (!universeCache || Date.now() - universeFetchedAt > UNIVERSE_REFRESH_MS) { await fetchUniverse(); } } catch (e) { console.warn('universe fetch failed', e); } const base = pie.base_currency || 'GBP'; const fx = (universeCache && universeCache.fx) || null; const enriched = pie.positions.map(p => enrichPosition(p, base, fx)) .sort((a, b) => (b._value || 0) - (a._value || 0)); const agg = aggregate(enriched); renderPanel(mount, pie, enriched, agg); } // --- Upload page helper ------------------------------------------------ async function handleUpload(form, file, statusEl) { statusEl.className = 'result'; statusEl.hidden = true; const fd = new FormData(); fd.append('file', file); try { const r = await fetch('/api/portfolio/parse', { method: 'POST', body: fd, credentials: 'same-origin', }); const data = await r.json(); if (!r.ok) { statusEl.className = 'result result--err'; statusEl.innerHTML = '
✕ Import failed
' + '
' + esc(data.detail || ('HTTP ' + r.status)) + '
'; statusEl.hidden = false; return false; } savePie(data); const warnings = (data.warnings || []).map(w => '
' + esc(w) + '
').join(''); statusEl.className = 'result result--ok'; statusEl.innerHTML = '
' + '▸ Parsed ' + esc(data.pie_name || 'pie') + ' · ' + 'stored locally' + '
' + '
' + '
Positions
' + data.positions.length + '
' + '
Invested
' + fmt(data.totals && data.totals.invested) + '
' + '
Value
' + fmt(data.totals && data.totals.value) + '
' + '
Result
' + signed(data.totals && data.totals.result) + '
' + '
' + warnings + '
' + 'Open dashboard →' + '
'; statusEl.hidden = false; return true; } catch (err) { statusEl.className = 'result result--err'; statusEl.innerHTML = '
✕ Import failed
' + '
' + esc(err.message) + '
'; statusEl.hidden = false; return false; } } // Public surface — usable from inline scripts on upload.html. window.CassandraPortfolio = { mountAndRender, handleUpload, loadPie, savePie, clearPie, }; // Auto-mount on dashboard load and refresh every minute. if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', mountAndRender); } else { mountAndRender(); } setInterval(mountAndRender, UNIVERSE_REFRESH_MS); })();