/* 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 =
'
';
}
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 +
'' +
'' +
'| Ticker | Name | ' +
'Qty | Avg | ' +
'Last | P/L | ' +
'% | ' +
'
' +
'' + rows + '' +
'
' +
'' +
'' +
'' +
'
' +
'';
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 +
'';
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);
})();