phase G: data minimisation + passwordless auth + DeepSeek-first LLM
Server no longer holds portfolios. Holdings live in the browser (localStorage); the server publishes an anonymous ticker_universe and a gzipped /api/universe payload identical for every authenticated user, so access patterns can't betray which tickers a user holds. AI commentary is generated ephemerally from the browser-supplied pie and the cost ledger row records no positions. Migrations 0009-0011 added the universe table and dropped positions / portfolio_snapshots / portfolios. Authentication is now e-mail OTP only. Migration 0010 dropped password_hash and email_verified (every active session is by construction proof of email control). The /signup endpoint is gone; signup and login share a single email-entry page. Email rendering is HTML+plain-text multipart with a shared brand palette (app/branding.py) asserted in sync with the CSS by a drift-detection test. LLM provider defaults to DeepSeek-direct (cheaper, api.deepseek.com) with OpenRouter as automatic fallback if DeepSeek fails. ai_log_job and indicator_summary_job now iterate the two tones (NOVICE, INTERMEDIATE) per cycle so the dashboard's tone toggle is instant; PROMPT_VERSION bumped to 6 with an educational anti-TA / anti-gambling stance baked into _CORE. NOVICE mode renders a curated glossary inline (CBOE VIX, yield curve, HY OAS, etc.) with JS-positioned tooltips that survive viewport edges and sticky bars. Model name and tokens hidden from the user UI; still recorded in StrategicLog.model and AICall for admin. Layout adds a sticky top nav, a sticky bottom markets bar (one chip per exchange with status LED + headline index + 1d change), and Phase H feedback reporting is queued in tasks/todo.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
480fd311c5
commit
6e7f57c6b2
54 changed files with 5005 additions and 916 deletions
|
|
@ -75,6 +75,9 @@ a:hover { text-decoration: underline; }
|
|||
background: var(--surface);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
}
|
||||
.app-header .brand {
|
||||
color: var(--accent);
|
||||
|
|
@ -104,6 +107,33 @@ a:hover { text-decoration: underline; }
|
|||
.theme-toggle__label::before { content: "◐ dark"; }
|
||||
[data-theme="light"] .theme-toggle__label::before { content: "◐ light"; }
|
||||
|
||||
/* Tone toggle (segmented control: Novice | Intermediate) */
|
||||
.tone-toggle {
|
||||
display: inline-flex;
|
||||
border: 1px solid var(--border);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.tone-toggle button {
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
border: 0;
|
||||
padding: 4px 10px;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
letter-spacing: inherit;
|
||||
text-transform: inherit;
|
||||
}
|
||||
.tone-toggle button + button { border-left: 1px solid var(--border); }
|
||||
.tone-toggle button:hover { color: var(--accent); }
|
||||
.tone-toggle[data-tone="NOVICE"] button[data-value="NOVICE"],
|
||||
.tone-toggle[data-tone="INTERMEDIATE"] button[data-value="INTERMEDIATE"] {
|
||||
background: var(--accent);
|
||||
color: var(--bg);
|
||||
}
|
||||
|
||||
.app-main {
|
||||
padding: 14px;
|
||||
display: grid;
|
||||
|
|
@ -124,9 +154,18 @@ a:hover { text-decoration: underline; }
|
|||
|
||||
#indicators-panel { grid-area: indicators; }
|
||||
#portfolio-panel { grid-area: portfolio; }
|
||||
#log-panel { grid-area: log; }
|
||||
#log-panel {
|
||||
grid-area: log;
|
||||
/* Don't stretch to fill both grid rows; if the log is shorter than
|
||||
the portfolio next to it, the surplus below would render as a big
|
||||
empty white box. Aligning to the start makes the panel shrink to
|
||||
its content and the dashboard background fills any gap. */
|
||||
align-self: start;
|
||||
}
|
||||
#news-panel { grid-area: news; }
|
||||
|
||||
/* Legacy footer rules — kept for the /api/health page which still uses
|
||||
the old class via the standalone HTML template. */
|
||||
.app-footer {
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 8px 18px;
|
||||
|
|
@ -138,6 +177,27 @@ a:hover { text-decoration: underline; }
|
|||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Sticky bottom markets bar — uses the same .mkt chip styling as the
|
||||
old dashboard header, extended with each market's headline index. */
|
||||
.markets-bar {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 50;
|
||||
background: var(--surface);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.markets-bar__inner {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 1px;
|
||||
background: var(--border);
|
||||
border: 0;
|
||||
}
|
||||
.markets-bar .mkt {
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
/* --- Panels ----------------------------------------------------------- */
|
||||
|
||||
.panel {
|
||||
|
|
@ -193,6 +253,10 @@ table.dense td[title] {
|
|||
border-bottom: 1px dotted color-mix(in srgb, var(--accent) 40%, transparent);
|
||||
border-bottom-width: 1px;
|
||||
}
|
||||
.pf-name.has-tip {
|
||||
cursor: help;
|
||||
border-bottom: 1px dotted color-mix(in srgb, var(--accent) 50%, transparent);
|
||||
}
|
||||
table.dense tr:hover td { background: color-mix(in srgb, var(--accent) 5%, transparent); }
|
||||
|
||||
.pos { color: var(--positive); }
|
||||
|
|
@ -251,7 +315,8 @@ table.dense tr.row-stale td { color: var(--dim); }
|
|||
}
|
||||
.mkt__dot {
|
||||
width: 8px; height: 8px; border-radius: 50%;
|
||||
grid-row: 1; grid-column: 1;
|
||||
grid-row: 1 / span 2; grid-column: 1;
|
||||
align-self: center;
|
||||
}
|
||||
.mkt--open .mkt__dot { background: var(--positive); box-shadow: 0 0 6px var(--positive); }
|
||||
.mkt--closed .mkt__dot { background: var(--dim); }
|
||||
|
|
@ -263,13 +328,30 @@ table.dense tr.row-stale td { color: var(--dim); }
|
|||
.mkt__state {
|
||||
grid-row: 1; grid-column: 3;
|
||||
font-size: 9.5px; letter-spacing: 0.08em;
|
||||
text-transform: lowercase;
|
||||
}
|
||||
.mkt--open .mkt__state { color: var(--positive); }
|
||||
.mkt--closed .mkt__state { color: var(--dim); }
|
||||
.mkt__index {
|
||||
grid-row: 2; grid-column: 2;
|
||||
font-size: 10.5px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 5px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mkt__index-label { color: var(--dim); }
|
||||
.mkt__index-price { color: var(--text); }
|
||||
.mkt__index-change.pos { color: var(--positive); }
|
||||
.mkt__index-change.neg { color: var(--negative); }
|
||||
.mkt__index-change.neu { color: var(--muted); }
|
||||
.mkt__index--empty { color: var(--dim); font-size: 10px; }
|
||||
.mkt__when {
|
||||
grid-row: 2; grid-column: 2 / -1;
|
||||
grid-row: 2; grid-column: 3;
|
||||
color: var(--muted); font-size: 10px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
}
|
||||
.mkt__when-label { color: var(--dim); }
|
||||
|
||||
|
|
@ -334,6 +416,41 @@ table.dense tr.row-stale td { color: var(--dim); }
|
|||
.ind-summary--pending { color: var(--dim); font-style: italic; }
|
||||
.ind-summary--pending .ind-summary__body { color: var(--dim); font-size: 12px; }
|
||||
|
||||
/* --- Glossary tooltips (Novice mode) --------------------------------- */
|
||||
/* The term gets a dotted underline. The actual tooltip is a single shared
|
||||
element (#glossary-tooltip) positioned by JS so it can flip on viewport
|
||||
edges and never clip behind sticky bars (which sit at z-index 50). */
|
||||
|
||||
.glossary {
|
||||
border-bottom: 1px dotted var(--accent);
|
||||
cursor: help;
|
||||
/* Same colour as surrounding text — only the underline signals "tooltip
|
||||
available", keeping the paragraph visually quiet. */
|
||||
}
|
||||
.glossary:focus { outline: 1px dotted var(--accent); outline-offset: 2px; }
|
||||
|
||||
#glossary-tooltip {
|
||||
position: fixed;
|
||||
z-index: 200; /* Above sticky bars (z-index 50). */
|
||||
max-width: 300px;
|
||||
padding: 9px 12px;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--accent);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
letter-spacing: 0;
|
||||
text-transform: none;
|
||||
font-weight: normal;
|
||||
box-shadow: 0 6px 18px rgba(0,0,0,0.35);
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 90ms ease;
|
||||
}
|
||||
#glossary-tooltip[data-visible="1"] { opacity: 1; }
|
||||
#glossary-tooltip[hidden] { display: none; }
|
||||
|
||||
/* --- Group tabs ------------------------------------------------------- */
|
||||
|
||||
.group-tabs {
|
||||
|
|
@ -407,6 +524,86 @@ table.dense tr.row-stale td { color: var(--dim); }
|
|||
.pf-stat-value.neu { color: var(--muted); }
|
||||
.pf-ccy { color: var(--dim); font-size: 11px; margin-left: 2px; }
|
||||
.pf-pct { color: var(--dim); font-size: 11px; margin-left: 4px; }
|
||||
.pf-pills { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 4px; }
|
||||
.pf-pill {
|
||||
font-size: 10.5px;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--muted);
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
padding: 2px 6px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.pf-warn {
|
||||
border-left: 3px solid var(--alert);
|
||||
background: color-mix(in srgb, var(--alert) 6%, transparent);
|
||||
color: var(--alert);
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
.pf-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.pf-actions button {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
background: var(--surface-2);
|
||||
color: var(--accent);
|
||||
border: 1px solid var(--border);
|
||||
padding: 7px 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.pf-actions button:hover { border-color: var(--accent); }
|
||||
.pf-actions button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.pf-actions .pf-secondary { color: var(--muted); }
|
||||
.pf-actions .pf-secondary:hover { color: var(--negative); border-color: var(--negative); }
|
||||
.pf-analysis {
|
||||
margin-top: 14px;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.pf-analysis__details { padding: 0; }
|
||||
.pf-analysis__head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
padding: 10px 16px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
list-style: none; /* hide native marker in Firefox */
|
||||
}
|
||||
.pf-analysis__head::-webkit-details-marker { display: none; }
|
||||
.pf-analysis__head-left::before {
|
||||
content: "▸ ";
|
||||
display: inline-block;
|
||||
width: 1em;
|
||||
color: var(--accent);
|
||||
transition: transform 120ms ease;
|
||||
}
|
||||
details[open] .pf-analysis__head-left::before { content: "▾ "; }
|
||||
.pf-analysis__head:hover { color: var(--accent); }
|
||||
.pf-analysis__head:hover .pf-analysis__head-left::before { color: var(--accent); }
|
||||
.pf-analysis__details[open] .pf-analysis__head {
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.pf-analysis__body {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 14px;
|
||||
line-height: 1.65;
|
||||
color: var(--text);
|
||||
white-space: pre-wrap;
|
||||
margin: 0;
|
||||
padding: 14px 16px 16px;
|
||||
}
|
||||
|
||||
/* --- Log panel -------------------------------------------------------- */
|
||||
|
||||
|
|
@ -583,13 +780,15 @@ table.dense tr.row-stale td { color: var(--dim); }
|
|||
/* --- Log metadata footer ---------------------------------------------- */
|
||||
|
||||
.log-meta {
|
||||
padding: 8px clamp(20px, 4vw, 56px) 16px;
|
||||
padding: 4px clamp(20px, 4vw, 56px) 6px;
|
||||
max-width: 76ch;
|
||||
margin: 0 auto;
|
||||
border-top: 1px dashed var(--border);
|
||||
color: var(--dim);
|
||||
font-size: 10.5px;
|
||||
font-family: var(--font-mono);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.log-meta__row { display: flex; flex-wrap: wrap; align-items: center; gap: 0; margin-top: 6px; }
|
||||
.log-meta__row--dim { color: var(--dim); font-size: 10.5px; }
|
||||
|
||||
/* --- Auth pages (login / signup, standalone — no app chrome) -------- */
|
||||
|
||||
|
|
@ -674,6 +873,32 @@ table.dense tr.row-stale td { color: var(--dim); }
|
|||
margin-bottom: 14px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.auth-info {
|
||||
border-left: 3px solid var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 6%, transparent);
|
||||
color: var(--accent);
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
margin-bottom: 14px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.auth-card__lede {
|
||||
font-size: 12.5px;
|
||||
color: var(--muted);
|
||||
margin: 0 0 16px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.auth-card__lede strong { color: var(--text); font-weight: normal; }
|
||||
.auth-card__resend {
|
||||
background: transparent !important;
|
||||
color: var(--muted) !important;
|
||||
border: 1px dashed var(--border) !important;
|
||||
font-size: 11px !important;
|
||||
}
|
||||
.auth-card__resend:hover {
|
||||
color: var(--accent) !important;
|
||||
border-color: var(--accent) !important;
|
||||
}
|
||||
|
||||
/* User chip in header */
|
||||
.user-chip {
|
||||
|
|
|
|||
447
app/static/js/portfolio.js
Normal file
447
app/static/js/portfolio.js
Normal file
|
|
@ -0,0 +1,447 @@
|
|||
/* 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 =
|
||||
'<div class="empty" style="padding:16px;">' +
|
||||
'No portfolio loaded in this browser. ' +
|
||||
'<a href="/upload">Import a T212 CSV →</a>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
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 '<span class="pf-pill">' + esc(c) + ' ' + share.toFixed(0) + '%</span>';
|
||||
})
|
||||
.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
|
||||
? ' <span class="pf-ccy">' + esc(p._currency) + '</span>'
|
||||
: '')
|
||||
: '—';
|
||||
const fxBadge = p._fx_missing
|
||||
? ' <span class="pf-ccy" title="FX rate missing">' + esc(p._currency || '?') + '</span>'
|
||||
: '';
|
||||
return '<tr>' +
|
||||
'<td class="label">' + esc(p.yahoo_ticker) + '</td>' +
|
||||
'<td>' + esc(p.name || '') + '</td>' +
|
||||
'<td class="num">' + fmt(p.qty, { maximumFractionDigits: 6 }) + '</td>' +
|
||||
'<td class="num neu">' + fmt(p.avg_cost) + '</td>' +
|
||||
'<td class="num">' + lastDisplay + fxBadge + '</td>' +
|
||||
'<td class="num ' + cls(p._ppl) + '">' + signed(p._ppl) + '</td>' +
|
||||
'<td class="num ' + cls(p._ppl_pct) + '">' + pct(p._ppl_pct) + '</td>' +
|
||||
'</tr>';
|
||||
}).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
|
||||
? '<div class="pf-warn">' + agg.missing_price +
|
||||
' position(s) have no live price — universe may be catching up.</div>'
|
||||
: '';
|
||||
|
||||
mount.innerHTML =
|
||||
'<div class="pf-overall">' +
|
||||
'<div class="pf-overall__head">' +
|
||||
'<span class="pf-name has-tip" title="' + esc(nameTooltip) + '">' +
|
||||
esc(pie.pie_name || 'Portfolio') +
|
||||
'</span>' +
|
||||
'<span class="pf-as-of" title="Prices fetched by the server at this time. Browser recomputes P/L on every refresh (~60s).">' +
|
||||
'prices ' + esc(pricesAsOf) +
|
||||
'</span>' +
|
||||
'</div>' +
|
||||
'<div class="pf-overall__grid">' +
|
||||
'<div class="pf-stat"><div class="pf-stat-label">Total</div>' +
|
||||
'<div class="pf-stat-value">' + fmt(agg.total_value) +
|
||||
' <span class="pf-ccy">' + esc(pie.base_currency || '') + '</span></div></div>' +
|
||||
'<div class="pf-stat"><div class="pf-stat-label">Invested</div>' +
|
||||
'<div class="pf-stat-value">' + fmt(agg.total_invested) + '</div></div>' +
|
||||
'<div class="pf-stat"><div class="pf-stat-label">Unrealised P/L</div>' +
|
||||
'<div class="pf-stat-value ' + cls(agg.total_ppl) + '">' + signed(agg.total_ppl) +
|
||||
(agg.total_ppl_pct != null
|
||||
? ' <span class="pf-pct">(' + pct(agg.total_ppl_pct) + ')</span>'
|
||||
: '') +
|
||||
'</div></div>' +
|
||||
'<div class="pf-stat"><div class="pf-stat-label">Positions</div>' +
|
||||
'<div class="pf-stat-value">' + agg.n_positions + '</div></div>' +
|
||||
'<div class="pf-stat" style="grid-column: span 2;">' +
|
||||
'<div class="pf-stat-label">Currency mix</div>' +
|
||||
'<div class="pf-pills">' + (ccyPills || '—') + '</div></div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
missingNote +
|
||||
'<table class="dense">' +
|
||||
'<thead><tr>' +
|
||||
'<th>Ticker</th><th>Name</th>' +
|
||||
'<th class="num">Qty</th><th class="num">Avg</th>' +
|
||||
'<th class="num">Last</th><th class="num">P/L</th>' +
|
||||
'<th class="num">%</th>' +
|
||||
'</tr></thead>' +
|
||||
'<tbody>' + rows + '</tbody>' +
|
||||
'</table>' +
|
||||
'<div class="pf-actions">' +
|
||||
'<button id="pf-analyze" type="button">Generate AI analysis</button>' +
|
||||
'<button id="pf-forget" type="button" class="pf-secondary">Forget this pie</button>' +
|
||||
'</div>' +
|
||||
'<div id="pf-analysis" class="pf-analysis" hidden></div>';
|
||||
|
||||
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 =
|
||||
'<details class="pf-analysis__details"' + openAttr + '>' +
|
||||
'<summary class="pf-analysis__head">' +
|
||||
'<span class="pf-analysis__head-left">' +
|
||||
'AI analysis' +
|
||||
'</span>' +
|
||||
'<span class="pf-as-of">' +
|
||||
esc((analysis.generated_at || '').slice(0, 19).replace('T', ' ')) +
|
||||
' UTC</span>' +
|
||||
'</summary>' +
|
||||
'<pre class="pf-analysis__body">' + esc(analysis.content) + '</pre>' +
|
||||
'</details>';
|
||||
}
|
||||
|
||||
async function runAnalysis(pie, enriched) {
|
||||
const out = document.getElementById('pf-analysis');
|
||||
const btn = document.getElementById('pf-analyze');
|
||||
out.hidden = false;
|
||||
out.innerHTML = '<div class="empty">generating…</div>';
|
||||
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 = '<div class="pf-warn">' + esc(data.detail || ('HTTP ' + r.status)) + '</div>';
|
||||
return;
|
||||
}
|
||||
// Persist before rendering so auto-refresh can re-hydrate.
|
||||
saveAnalysis(data);
|
||||
showAnalysis(data, { open: true });
|
||||
} catch (e) {
|
||||
out.innerHTML = '<div class="pf-warn">' + esc(e.message) + '</div>';
|
||||
} 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 =
|
||||
'<div class="result__head">✕ Import failed</div>' +
|
||||
'<div class="result__row">' + esc(data.detail || ('HTTP ' + r.status)) + '</div>';
|
||||
statusEl.hidden = false;
|
||||
return false;
|
||||
}
|
||||
savePie(data);
|
||||
|
||||
const warnings = (data.warnings || []).map(w =>
|
||||
'<div class="result__warn">' + esc(w) + '</div>').join('');
|
||||
|
||||
statusEl.className = 'result result--ok';
|
||||
statusEl.innerHTML =
|
||||
'<div class="result__head">' +
|
||||
'▸ Parsed <strong>' + esc(data.pie_name || 'pie') + '</strong> · ' +
|
||||
'<span class="result__tag">stored locally</span>' +
|
||||
'</div>' +
|
||||
'<div class="result__grid">' +
|
||||
'<div><div class="k">Positions</div><div class="v">' + data.positions.length + '</div></div>' +
|
||||
'<div><div class="k">Invested</div><div class="v">' + fmt(data.totals && data.totals.invested) + '</div></div>' +
|
||||
'<div><div class="k">Value</div><div class="v">' + fmt(data.totals && data.totals.value) + '</div></div>' +
|
||||
'<div><div class="k">Result</div><div class="v ' +
|
||||
((data.totals && data.totals.result >= 0) ? 'pos' : 'neg') + '">' +
|
||||
signed(data.totals && data.totals.result) + '</div></div>' +
|
||||
'</div>' +
|
||||
warnings +
|
||||
'<div class="result__row" style="margin-top:14px;">' +
|
||||
'<a href="/">Open dashboard →</a>' +
|
||||
'</div>';
|
||||
statusEl.hidden = false;
|
||||
return true;
|
||||
} catch (err) {
|
||||
statusEl.className = 'result result--err';
|
||||
statusEl.innerHTML =
|
||||
'<div class="result__head">✕ Import failed</div>' +
|
||||
'<div class="result__row">' + esc(err.message) + '</div>';
|
||||
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);
|
||||
})();
|
||||
Loading…
Add table
Add a link
Reference in a new issue