/* Portfolio Analyst — input → SSE progress → results summary. No frameworks. */
"use strict";
const $ = (s, el) => (el || document).querySelector(s);
const el = (tag, cls, html) => { const e = document.createElement(tag); if (cls) e.className = cls; if (html !== undefined) e.innerHTML = html; return e; };
const esc = s => String(s ?? "").replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
const pct = (x, signed = true) => (x === null || x === undefined || isNaN(x)) ? "–"
: ((signed && x > 0 ? "+" : "") + (x * 100).toFixed(1) + "%");
const SECTOR_COLORS = ["#6c8cff", "#2fbf71", "#e8a838", "#e35d6a", "#9b6cff", "#39c2c9",
"#ff8cc3", "#a3d267", "#c98d5a", "#7f93b8", "#5ad2a8", "#d26767"];
/* ───────── view switching ───────── */
function show(view) {
for (const v of ["input", "progress", "results"]) $("#view-" + v).hidden = (v !== view);
window.scrollTo({ top: 0 });
}
/* ───────── input rows ───────── */
const rowsEl = $("#rows");
function addRow(query = "", entry = "", qty = "", bought = "") {
const tr = el("tr");
tr.innerHTML = `
× `;
tr.querySelector(".rm").onclick = () => { tr.remove(); if (!rowsEl.children.length) addRow(); };
const q = tr.querySelector(".q"), e = tr.querySelector(".e");
let timer = null;
const kick = () => { clearTimeout(timer); delete tr.dataset.split; delete tr.dataset.presplit;
timer = setTimeout(() => resolveRow(tr), 500); };
q.addEventListener("change", kick); e.addEventListener("change", kick);
rowsEl.appendChild(tr);
return tr;
}
async function resolveRow(tr, attempt = 0) {
const q = tr.querySelector(".q").value.trim();
const entry = parseFloat(tr.querySelector(".e").value);
const out = tr.querySelector(".resolved");
if (!q) { out.textContent = ""; return; }
out.className = "resolved"; out.textContent = attempt ? "retrying…" : "looking up…";
let r = null;
try {
const resp = await fetch("/api/resolve", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query: q, entry: isFinite(entry) ? entry : null }),
});
if (resp.ok) r = await resp.json();
} catch { r = null; }
if (r && !(r.retry && !r.ok)) { applyResolved(tr, r); return; }
// a transient failure (server waking up, data-source blip): one automatic retry, then a
// click-to-retry — a dead "lookup failed" on a GOOG row helps nobody
if (attempt < 1) { setTimeout(() => resolveRow(tr, attempt + 1), 1500); return; }
out.className = "resolved err retry";
out.textContent = (r && r.error) ? r.error : "lookup failed (server busy) — click to retry";
out.title = "click to retry";
out.onclick = () => { out.onclick = null; resolveRow(tr, 0); };
}
function applyResolved(tr, r) {
const out = tr.querySelector(".resolved");
{
if (!r.ok) { out.className = "resolved err"; out.textContent = r.error; tr.querySelector(".q").classList.add("bad"); return; }
tr.querySelector(".q").classList.remove("bad");
tr.dataset.symbol = r.symbol;
let html = `${esc(r.symbol)} ${esc(r.name || "")} · ${Number(r.price).toFixed(2)} ${esc(r.currency)}`;
if (r.pl_pct !== undefined)
html += ` · ${pct(r.pl_pct)} since entry `;
out.innerHTML = html;
markLots();
for (const w of r.warnings || []) {
if ((w.code === "presplit_ask" || w.code === "presplit") && w.suggest && w.suggest.field === "entry") {
// never rewrite the user's numbers: whether the QUANTITY is pre- or post-split only
// they know (7 shares at 171 vs 70 at 17.13) — ask, with the maths shown
if (tr.dataset.split === "keep") {
out.appendChild(el("div", "warn-note", `↕ ${esc(w.message)} Kept as entered (you confirmed it).`));
continue;
}
tr.dataset.presplit = "1";
const entryNow = parseFloat(tr.querySelector(".e").value), qtyNow = parseFloat(tr.querySelector(".n").value);
const ratio = entryNow / Number(w.suggest.value);
const div = el("div", "warn-note", `↕ ${esc(w.message)} Which is right? `);
const mk = (label, fn) => { const b = el("button", "warn-apply", label); b.onclick = (ev) => { ev.preventDefault(); fn(); }; div.appendChild(b); div.appendChild(document.createTextNode(" ")); };
mk(`Adjust price & quantity → ${Number(w.suggest.value).toFixed(2)}${isFinite(qtyNow) ? ` × ${Math.round(qtyNow * ratio)} shares` : ""}`, () => {
tr.dataset.split = "both"; tr.querySelector(".e").value = w.suggest.value;
if (isFinite(qtyNow)) tr.querySelector(".n").value = Math.round(qtyNow * ratio * 1000) / 1000;
delete tr.dataset.presplit; resolveRow(tr);
});
mk(`Adjust price only → ${Number(w.suggest.value).toFixed(2)} (quantity is already post-split)`, () => {
tr.dataset.split = "price"; tr.querySelector(".e").value = w.suggest.value; delete tr.dataset.presplit; resolveRow(tr);
});
mk(`Keep ${entryNow} as entered`, () => { tr.dataset.split = "keep"; delete tr.dataset.presplit; resolveRow(tr); });
out.appendChild(div);
continue;
}
const div = el("div", "warn-note", `⚠ ${esc(w.message)} `);
if (w.suggest) {
const btn = el("button", "warn-apply", esc(w.suggest.label));
btn.onclick = (ev) => {
ev.preventDefault();
tr.querySelector(w.suggest.field === "entry" ? ".e" : ".q").value = w.suggest.value;
resolveRow(tr);
};
div.appendChild(btn);
div.appendChild(el("span", "warn-keep", " or keep yours if it's right."));
}
out.appendChild(div);
}
}
}
/* several rows resolving to one symbol are LOTS of the same holding: show the running
average so the user sees what the analysis will use (backend does the same maths) */
function markLots() {
const groups = {};
for (const tr of rowsEl.children) {
const sym = tr.dataset.symbol;
if (sym) (groups[sym] = groups[sym] || []).push(tr);
}
for (const tr of rowsEl.children) tr.querySelectorAll(".lot-chip").forEach(x => x.remove());
for (const trs of Object.values(groups)) {
if (trs.length < 2) continue;
const lots = trs.map(tr => ({ e: parseFloat(tr.querySelector(".e").value), n: parseFloat(tr.querySelector(".n").value) }));
const buys = lots.filter(l => isFinite(l.e) && !(l.n < 0));
const sized = buys.every(l => l.n > 0);
const bq = buys.reduce((a, l) => a + (l.n > 0 ? l.n : 0), 0);
const avg = sized && bq > 0 ? buys.reduce((a, l) => a + l.n * l.e, 0) / bq
: buys.reduce((a, l) => a + l.e, 0) / Math.max(buys.length, 1);
const net = bq - lots.filter(l => l.n < 0).reduce((a, l) => a - l.n, 0);
trs.forEach((tr, i) => {
const chip = el("span", "sig lot-chip",
`lot ${i + 1} of ${trs.length} · avg ${avg.toFixed(2)}${sized ? ` · net ${net}` : " (add quantities to weight it)"}`);
tr.querySelector(".resolved").appendChild(chip);
});
}
}
rowsEl.addEventListener("change", (ev) => { if (ev.target.classList.contains("n") || ev.target.classList.contains("e")) markLots(); });
$("#add-row").onclick = () => addRow();
/* ───────── file import: extract → prefill → user reviews & confirms ───────── */
$("#import-file").onclick = () => $("#file-input").click();
function importProgress(st, label) {
st.hidden = false;
st.innerHTML = `${esc(label)}
`;
}
$("#file-input").addEventListener("change", async (e) => {
const f = e.target.files && e.target.files[0];
if (!f) return;
const st = $("#import-status");
importProgress(st, `Reading ${f.name} — extracting holdings (aggregating lots)…`);
const fd = new FormData(); fd.append("file", f);
let resp;
try { resp = await fetch("/api/import", { method: "POST", body: fd }); }
catch { st.textContent = "Upload failed — try again or enter holdings manually."; return; }
if (!resp.ok) {
const d = await resp.json().catch(() => ({}));
st.textContent = d.detail || "Couldn't read that file.";
return;
}
const d = await resp.json();
rowsEl.innerHTML = "";
const trs = d.rows.map(r => addRow(r.query, r.entry ?? "", r.qty ?? ""));
// one server round trip resolves every row in parallel (and pre-warms the analysis data)
importProgress(st, `Found ${d.rows.length} holdings — verifying live prices for all ${trs.length} at once…`);
try {
const br = await fetch("/api/resolve_batch", { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ rows: d.rows.map(r => ({ query: r.query, entry: r.entry ?? null })) }) }).then(x => x.json());
if (!Array.isArray(br.results)) throw new Error("no results");
br.results.forEach((r, i) => {
if (!trs[i]) return;
if (r && r.retry && !r.ok) resolveRow(trs[i], 1); // per-row retry for the transient failures only
else applyResolved(trs[i], r);
});
} catch {
for (let i = 0; i < trs.length; i += 5) await Promise.all(trs.slice(i, i + 5).map(tr => resolveRow(tr)));
}
st.textContent = `Imported ${d.rows.length} holding${d.rows.length > 1 ? "s" : ""} — ` +
`review each row and fix anything before running.` +
(d.note ? ` Note: ${d.note}` : "");
e.target.value = "";
});
/* ───────── global stats ticker ───────── */
(async function ticker() {
let s;
try { s = await fetch("/api/stats").then(r => r.json()); } catch { return; }
const chips = [];
if (s.n_verdicts) chips.push(`📋 ${s.n_verdicts} verdict${s.n_verdicts > 1 ? "s" : ""} issued${s.since ? " since " + s.since : ""}`);
if (s.n_cases) chips.push(`👁 ${s.n_cases} portfolio${s.n_cases > 1 ? "s" : ""} on standing watch`);
if (s.show_perf && s.by_action) {
for (const [a, v] of Object.entries(s.by_action))
chips.push(`${a} calls (${v.n}): ${pct(v.avg_return)} since called`);
if (s.spy_return !== null && s.spy_return !== undefined)
chips.push(`S&P 500 over the same period: ${pct(s.spy_return)}`);
chips.push("Every verdict counted — nothing cherry-picked");
} else {
chips.push(`🔓 Track record unlocks at ${s.unlock_at} verdicts — every call is being scored`);
}
chips.push("🩺 5 analysts · 3 AI models · Damodaran valuation on every profitable holding");
chips.push("Educational analysis — not investment advice");
const html = chips.map(c => `${c} `).join("· ");
$("#ticker").innerHTML = html + "· " + html; // duplicated for seamless loop
$("#ticker-wrap").hidden = false;
})();
$("#load-sample").onclick = async () => {
const sample = await fetch("/api/sample").then(r => r.json());
rowsEl.innerHTML = "";
for (const s of sample.rows) { const tr = addRow(s.query, s.entry, s.qty ?? ""); resolveRow(tr); }
};
for (let i = 0; i < 3; i++) addRow();
/* ───────── verdict ledger: load a tracked portfolio, standing watch ───────── */
let currentRun = null;
let currentCode = null;
function renderWatch(code, data) {
currentCode = code;
$("#watch-card").hidden = false;
$("#watch-code").textContent = code;
const tr = $("#track-record"); tr.innerHTML = "";
const t = data.track;
if (t && t.by_action) {
let html = `Committee track record — ${t.n_verdicts} verdicts since ${esc(t.since)}` +
(t.spy_return !== null && t.spy_return !== undefined ? ` · S&P 500 ${pct(t.spy_return)} over the same period` : "") + `
`;
for (const [action, v] of Object.entries(t.by_action)) {
const cls = v.avg_return >= 0 ? "up" : "dn";
html += `${esc(action)} (${v.n}): ${pct(v.avg_return)} `;
}
tr.innerHTML = html;
} else {
tr.innerHTML = `Track record starts building from your first saved verdict.
`;
}
const al = $("#watch-alerts"); al.innerHTML = "";
const icons = { move: "📉", trend: "⚠️", stale: "⏳", event: "🚨" };
for (const a of (data.alerts || [])) {
al.appendChild(el("div", "alert-row",
`${icons[a.kind] || "•"} ${esc(a.date || "")} ${esc(a.msg)}`));
}
if (!(data.alerts || []).length)
al.appendChild(el("div", "alert-row quiet", "No alerts — the watch is quiet."));
}
async function loadCode() {
const code = $("#code-input").value.trim().toLowerCase();
const st = $("#code-status"); st.textContent = "";
if (!code) return;
st.textContent = "loading…";
const resp = await fetch("/api/portfolio/" + encodeURIComponent(code));
if (!resp.ok) { st.textContent = "code not found"; return; }
const p = await resp.json();
st.textContent = `case open · ${p.n_runs} verdict${p.n_runs > 1 ? "s" : ""} on record`;
try { localStorage.setItem("vitals_code", code); } catch {}
try { history.replaceState(null, "", "?p=" + code); } catch {}
rowsEl.innerHTML = "";
for (const r of (p.rows || [])) { const tr = addRow(r.query, r.entry, r.qty ?? ""); resolveRow(tr); }
if (!rowsEl.children.length) addRow();
const ctx = p.context || {};
if (ctx.horizon) $("#ctx-horizon").value = ctx.horizon;
if (ctx.risk) $("#ctx-risk").value = ctx.risk;
if (ctx.objective) $("#ctx-objective").value = ctx.objective;
if (ctx.loss_tolerance) $("#ctx-loss").value = ctx.loss_tolerance;
if (ctx.liquidity_need) $("#ctx-liq").value = ctx.liquidity_need;
if (ctx.exclusions && $("#ctx-excl")) $("#ctx-excl").value = ctx.exclusions;
if (ctx.max_positions && $("#ctx-maxpos")) $("#ctx-maxpos").value = ctx.max_positions;
if (ctx.horizon || ctx.risk || ctx.objective) $("#ctx-panel").open = true;
renderWatch(code, p);
}
$("#load-code").onclick = loadCode;
$("#code-input").addEventListener("keydown", e => { if (e.key === "Enter") loadCode(); });
$("#check-now").onclick = async () => {
if (!currentCode) return;
const st = $("#check-status"); st.textContent = "checking prices, trends & headlines…";
const resp = await fetch("/api/check/" + encodeURIComponent(currentCode), { method: "POST" });
if (!resp.ok) { const d = await resp.json().catch(() => ({})); st.textContent = d.detail || "check failed"; return; }
renderWatch(currentCode, await resp.json());
st.textContent = "checked just now";
};
$("#save-track").onclick = async () => {
if (!currentRun) return;
const resp = await fetch("/api/save", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ run_id: currentRun, code: currentCode }),
});
const out = $("#save-result"); out.hidden = false;
if (!resp.ok) { const d = await resp.json().catch(() => ({})); out.textContent = d.detail || "save failed"; return; }
const d = await resp.json();
currentCode = d.code;
out.innerHTML = d.appended
? `Verdict added to case ${esc(d.code)} — the track record grows.`
: `Saved. Your case ID is ${esc(d.code)} — it's remembered on this device, ` +
`and this page's link now carries it too. Enter it anywhere to reopen your case.`;
try { localStorage.setItem("vitals_code", d.code); } catch {}
try { history.replaceState(null, "", "?p=" + d.code); } catch {}
$("#save-track").hidden = true;
};
/* auto-reopen a case: shareable ?p=code link first, then this device's remembered code */
(function autoLoad() {
let code = null;
try { code = new URLSearchParams(location.search).get("p"); } catch {}
if (!code) { try { code = localStorage.getItem("vitals_code"); } catch {} }
if (code && /^[a-f0-9]{8}$/.test(code)) {
$("#code-input").value = code;
loadCode();
}
})();
/* ───────── analyze + SSE ───────── */
$("#analyze").onclick = async () => {
const rows = [...rowsEl.children].map(tr => ({
query: tr.querySelector(".q").value.trim(),
entry: parseFloat(tr.querySelector(".e").value) || null,
qty: parseFloat(tr.querySelector(".n").value) || null,
bought: (tr.querySelector(".b") && tr.querySelector(".b").value) || null,
split: tr.dataset.split || null,
})).filter(r => r.query);
const errEl = $("#input-error"); errEl.hidden = true;
if (!rows.length) { errEl.hidden = false; errEl.textContent = "Add at least one holding."; return; }
const pending = [...rowsEl.children].filter(tr => tr.dataset.presplit === "1").map(tr => tr.querySelector(".q").value.trim());
if (pending.length) {
errEl.hidden = false;
errEl.textContent = `Answer the split question on ${pending.join(", ")} first — the entry price looks pre-split and only you know whether the quantity is.`;
return;
}
if (rows.some(r => !r.entry)) { errEl.hidden = false; errEl.textContent = "Every holding needs an entry price."; return; }
const context = {
horizon: $("#ctx-horizon").value || null,
risk: $("#ctx-risk").value || null,
objective: $("#ctx-objective").value || null,
loss_tolerance: $("#ctx-loss").value || null,
liquidity_need: $("#ctx-liq").value || null,
exclusions: ($("#ctx-excl") && $("#ctx-excl").value.trim()) || null,
max_positions: ($("#ctx-maxpos") && $("#ctx-maxpos").value) || null,
wrapper: ($("#ctx-wrapper") && $("#ctx-wrapper").value) || null,
};
let resp;
try {
resp = await fetch("/api/analyze", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ rows, context }),
});
} catch { errEl.hidden = false; errEl.textContent = "Could not reach the server."; return; }
if (!resp.ok) {
const d = await resp.json().catch(() => ({}));
errEl.hidden = false; errEl.textContent = d.detail || "Could not start the analysis.";
return;
}
const { run_id } = await resp.json();
currentRun = run_id;
startProgress(run_id);
};
function startProgress(runId) {
show("progress");
$("#bar").style.width = "2%";
$("#progress-caption").textContent = "Starting the analysis…";
document.querySelectorAll(".chip").forEach(c => c.classList.remove("lit"));
const es = new EventSource("/api/events/" + runId);
es.onmessage = ev => {
const d = JSON.parse(ev.data);
if (d.type === "progress") {
$("#bar").style.width = Math.max(2, d.pct) + "%";
$("#progress-caption").textContent = d.caption;
if (d.chip) { const c = document.querySelector(`.chip[data-chip="${d.chip}"]`); if (c) c.classList.add("lit"); }
} else if (d.type === "done") {
es.close(); renderResults(d.result);
} else if (d.type === "error") {
es.close(); show("input");
const errEl = $("#input-error"); errEl.hidden = false; errEl.textContent = d.message;
}
};
es.onerror = () => { /* EventSource auto-reconnects; terminal states replay on reconnect */ };
}
/* ───────── results rendering ───────── */
function renderResults(R) {
show("results");
$("#save-track").hidden = false;
$("#save-result").hidden = true;
if (currentCode) { // analyzing a tracked portfolio — append this verdict automatically
fetch("/api/save", { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ run_id: currentRun, code: currentCode }) })
.then(r => r.ok ? r.json() : null)
.then(d => { if (d) { $("#save-track").hidden = true;
const out = $("#save-result"); out.hidden = false;
out.innerHTML = `Verdict recorded for portfolio ${esc(d.code)} .`; } })
.catch(() => {});
}
// hero gauge
const score = R.health_score;
const arc = $("#gauge-arc");
const len = arc.getTotalLength();
arc.style.strokeDasharray = len;
arc.style.strokeDashoffset = len * (1 - score / 100);
arc.style.stroke = score >= 70 ? "var(--good)" : score >= 45 ? "var(--warn)" : "var(--bad)";
$("#gauge-num").textContent = score;
const pil = $("#health-pillars");
if (pil) {
const hb = R.health_breakdown || {};
const rows = hb.pillars || [];
pil.hidden = !rows.length;
pil.innerHTML = rows.map(p => {
const col = p.score >= 65 ? "var(--good)" : p.score >= 45 ? "var(--accent)" : "var(--warn)";
return `${esc(p.label)} ${Math.round(p.weight * 100)}% ` +
`${Math.round(p.score)}
`;
}).join("") + (hb.tolerance_penalty ? `−${Math.abs(hb.tolerance_penalty)} for breaching your stated loss tolerance
` : "");
}
$("#verdict").textContent = R.verdict;
// action strip
const strip = $("#action-strip"); strip.innerHTML = "";
const order = [["Buy More", "buy"], ["Keep", "keep"], ["Trim", "trim"], ["Sell", "sell"]];
for (const [a, cls] of order) {
const n = R.action_counts[a];
if (n) strip.appendChild(el("span", "pill " + cls, `${n} ${a}`));
}
if (R.total_pl_pct !== null && R.total_pl_pct !== undefined)
strip.appendChild(el("span", "pill pl",
`Portfolio P/L ${pct(R.total_pl_pct)} `));
$("#approx-banner").hidden = !(R.metrics && R.metrics.equal_weighted);
// highlights
const hl = $("#highlights"); hl.innerHTML = "";
for (const h of R.highlights) {
hl.appendChild(el("div", "hl",
`${esc(h.title)}
${esc(h.detail)}
${esc(h.lens || "")} lens `));
}
// recommendations table
const body = $("#rec-body"); body.innerHTML = "";
const cls = { "Buy More": "buy", "Keep": "keep", "Trim": "trim", "Sell": "sell" };
for (const r of R.holdings) {
const tr = el("tr", "main");
tr.innerHTML = `
${esc(r.ticker)} ${esc(r.name)}
${pct(r.weight, false)}
${pct(r.pl_pct)}${r.entry_note ? ` ${r.entry_note.includes("kept") ? "split: kept as entered" : "split-adjusted"} ` : ""}${r.n_lots > 1
? ` ${r.n_lots} lots · avg ${Number(r.entry).toFixed(2)} ` : ""}
${r.upside === null || r.upside === undefined
? (r.dcf_note ? `${esc(r.dcf_note)} ` : "–")
: `${pct(r.upside)} `}
${esc(r.action)} ${r.watch ? ' watch ' : ""}
${esc(r.rationale)} `;
const dtr = el("tr", "detail"); dtr.hidden = true;
dtr.innerHTML = `${detailHTML(r)} `;
tr.onclick = () => { dtr.hidden = !dtr.hidden; };
body.appendChild(tr); body.appendChild(dtr);
}
renderPlan(R);
renderDonut(R);
renderWeights(R);
// scorecard
const sc = $("#scorecard"); sc.innerHTML = "";
for (const c of R.risk_scorecard)
sc.appendChild(el("div", "sc",
`${c.status} ${esc(c.check)} ${esc(c.detail)}
`));
const st = R.strategist || {}; const sr = $("#strategy-read"); sr.innerHTML = "";
if (st.strategy_read) sr.innerHTML =
`Strategy read: ${esc(st.strategy_read)}` +
(st.style_tilt ? ` Style: ${esc(st.style_tilt)}.` : "") +
(st.gaps && st.gaps.length ? ` Missing exposure: ${esc(st.gaps.join(", "))}.` : "");
// alternatives (signal tags show which engine surfaced each pick)
const alts = R.alternatives || [];
$("#alt-section").hidden = !alts.length;
const av = $("#alternatives"); av.innerHTML = "";
const needs = R.portfolio_needs || {};
const needLabels = Object.values(needs).map(n => n.label).filter(Boolean);
if (alts.length && needLabels.length)
av.appendChild(el("div", "needs-line", `What this portfolio needs: ${esc(needLabels.join(" · "))}`));
for (const g of ((R.plan || {}).gap_search || [])) {
av.appendChild(el("div", "needs-line dim", `${esc(g.sector)}: ${g.screened} names screened — ` +
(g.buyable && g.buyable.length ? `buyable today: ${esc(g.buyable.join(", "))}` : "none buyable today") +
(g.why_not ? ` (${esc(g.why_not)})` : "") + (g.bench && g.bench.length ? `; on the bench with entry conditions: ${esc(g.bench.join(", "))}` : "")));
}
for (const a of alts) {
const tags = (a.signals || []).filter(s => s.includes("smart") || s.includes("quant") || s.includes("local") || s.includes("US-listed")).map(s =>
`${esc(s.includes("smart") ? "🐋 smart money" : s.includes("quant") ? "🧮 quant rank" : s.includes("local") ? "📍 local market" : "🇺🇸 US-listed")} `).join(" ");
const fit = a.fit || {};
const fixes = (fit.fixes || []).map(f => `✓ ${esc(f)} `).join(" ") +
(fit.cautions || []).map(f => `⚠ ${esc(f)} `).join(" ");
const com = a.committee;
const comLine = com ? `Committee ${com.conviction}/100 on adding it` +
((com.risks || []).length ? ` · top risk: ${esc(com.risks[0])}` : "") + `
` : "";
av.appendChild(el("div", "alt", `${esc(a.ticker)} ${tags}${esc(a.reason)}
` +
(fixes ? `${fixes}
` : "") + comLine));
}
// recommended target portfolio (only when the user gave context)
const target = R.target_portfolio || [];
$("#target-section").hidden = !target.length;
if (target.length) {
const ctx = R.context || {};
$("#target-note").textContent =
`Built for: ${[ctx.horizon, ctx.risk, ctx.objective].filter(Boolean).join(" · ")} — ` +
`committee conviction sizes positions under risk-profile caps. Educational illustration, not advice.`;
const tb = $("#target-body"); tb.innerHTML = "";
for (const r of target) {
const d = r.delta;
const dCls = d > 0.005 ? "up" : d < -0.005 ? "dn" : "";
const tr = el("tr");
tr.innerHTML = `
${esc(r.ticker)} ${r.is_new ? ' New ' : ""}
${esc(r.sector)}
${pct(r.current_w, false)}
${pct(r.target_w, false)}
${d > 0 ? "▲" : d < 0 ? "▼" : "·"} ${pct(Math.abs(d), false)} `;
tb.appendChild(tr);
}
}
// footer
renderRisk(R);
renderRejected(R);
$("#download").href = "/api/report/" + currentRun;
$("#audit").href = "/api/audit/" + currentRun;
$("#meta").textContent = `Generated ${R.generated_at}` +
(R.metrics.equal_weighted ? " · equal-weighted (no quantities given)" : "") +
(R.llm_used ? "" : " · quantitative view only — no AI key configured");
$("#disclaimer").textContent = R.disclaimer;
}
function renderPlan(R) {
const plan = R.plan;
$("#plan-section").hidden = !plan;
if (!plan) return;
const revival = plan.mode === "revival";
$("#plan-title").textContent = revival ? "🚑 Revival plan" : "⚡ Enhancement plan";
$("#plan-pitch").textContent = plan.pitch || "";
const mv = $("#plan-moves"); mv.innerHTML = "";
(plan.moves || []).forEach((m, i) => {
mv.appendChild(el("div", "move",
`${i + 1} ${esc(m.title)} ${esc(m.detail)}
`));
});
const extraBox = $("#plan-extra"); if (extraBox) {
let x = "";
if (plan.do_nothing) x += `✓ Do nothing Nothing clears the hurdle for a trade. Buffett's punch card: inactivity is a decision, and here it is the right one. Re-check when a sell trigger fires.
`;
for (const h of (plan.harvest_pairs || [])) {
x += `$ Tax-loss harvest: sell ${esc(h.sell)}, buy ${esc(h.buy)} ${esc(h.note)} (${esc(h.buy_name)}, correlation ${h.correlation})
`;
}
const pm = R.premortem;
if (pm && pm.story) x += `⚠ Pre-mortem — how ${esc(pm.ticker)} loses 40% from here ${esc(pm.story)}
`;
extraBox.innerHTML = x; extraBox.hidden = !x;
}
// switch pairs
const pp = $("#plan-pairs"); pp.innerHTML = "";
for (const p of (plan.switch_pairs || [])) {
const cmp = p.compare || {}; const o = cmp.out || {}, n = cmp.in || {};
const washNote = p.tax_note ? ` wash-sale rule ` : "";
const line = (s) => [
s.pe !== null && s.pe !== undefined ? `P/E ${Number(s.pe).toFixed(0)}` : null,
s.rev_growth !== null && s.rev_growth !== undefined ? `growth ${pct(s.rev_growth)}` : null,
s.vs_200d !== null && s.vs_200d !== undefined ? `${pct(s.vs_200d)} vs 200-day` : null,
].filter(Boolean).join(" · ") || "—";
pp.appendChild(el("div", "pair",
`${esc(p.out)} ➜ ` +
`${esc(p.in)} ${esc(p.sector || "")}` +
`${p.same_sector ? " · same sector" : ""}
` +
`${esc(p.out)}: ${esc(line(o))} ` +
`${esc(p.in)}: ${esc(line(n))}
`));
}
// before/after outlook
const ol = plan.outlook || {}; const cur = ol.current || {}, pl = ol.planned || {};
const cell = (lab, a, b, fmt) => {
const f = fmt || (x => x === null || x === undefined ? "–" : x);
return `${lab} ${f(a)} ➜ ${f(b)}
`;
};
const outEl = $("#plan-outlook");
outEl.innerHTML =
cell("Est. health", cur.est_health, pl.est_health) +
cell("Market sensitivity (beta)", cur.beta, pl.beta) +
cell("Biggest sector", cur.top_sector ? `${cur.top_sector[0]} ${pct(cur.top_sector[1], false)}` : "–",
pl.top_sector ? `${pl.top_sector[0]} ${pct(pl.top_sector[1], false)}` : "–") +
((cur.expected_excess_12m !== null && cur.expected_excess_12m !== undefined &&
pl.expected_excess_12m !== null && pl.expected_excess_12m !== undefined)
? cell("Model 12-mo edge vs market", pct(cur.expected_excess_12m), pct(pl.expected_excess_12m))
: "");
renderConstruction(plan);
// orders
const ob = $("#plan-orders-body"); ob.innerHTML = "";
for (const o of (plan.orders || [])) {
const tr = el("tr");
tr.innerHTML = `${o.side}
${esc(o.ticker)} ${o.is_new ? ' new ' : ""}
${pct(Math.abs(o.delta_pct), false)} of portfolio
${o.amount_usd ? "$" + Number(o.amount_usd).toLocaleString() +
(o.shares ? ` · ~${o.shares} sh` : "") : "—"}${o.cost_note ? ` ${esc(o.cost_note)} ` : ""} `;
ob.appendChild(tr);
}
$("#plan-orders-note").textContent =
(plan.cash ? `Sells fund the buys; ${pct(plan.cash, false)} of the book stays in cash as dry powder for the watchlist. ` : `Cash-neutral: sells fund the buys. `) +
`Based on ${plan.based_on}.` +
((plan.staged_trims || []).length ? ` ${plan.staged_trims.join(", ")} trimmed in stages — the book cannot absorb the full sale at sensible sizes; finish the cut as new ideas clear the bar.` : "") +
` Educational illustration, not advice.`;
// triage
const tw = $("#plan-triage-wrap");
tw.hidden = !(plan.triage || []).length;
const tg = $("#plan-triage"); tg.innerHTML = "";
const dispIcon = { "average down": "🟢", "switch": "🔁", "exit to fund": "🔻", "hold and wait": "⏸", "hold — on watch": "👁" };
for (const t of (plan.triage || [])) {
tg.appendChild(el("div", "triage-row",
`${dispIcon[t.disposition] || "•"} ${esc(t.ticker)} — ${esc(t.disposition)} : ${esc(t.note)}`));
}
}
function sentDot(s) {
const l = String(s || "").toLowerCase();
if (l.includes("bull")) return ' ';
if (l.includes("bear")) return ' ';
return ' ';
}
function detailHTML(r) {
const lensNames = { value: "🏛 Value", growth: "🚀 Growth", macro: "🌍 Macro", technical: "📈 Technical", risk: "🛡 Risk" };
const convColor = r.conviction >= 63 ? "var(--good)" : r.conviction >= 45 ? "var(--accent)" : "var(--warn)";
let left = `Committee conviction
${r.conviction}/100 median across ${r.n_lenses} lenses` +
(r.dispersion >= 30 ? ` — wide disagreement (${r.dispersion} pts) ` : "") + ` `;
const scores = r.lens_scores || {};
for (const k of Object.keys(lensNames)) if (scores[k] !== undefined)
left += `
${lensNames[k]}: ${Math.round(scores[k])} `;
left += ivBar(r) + `
`;
const newsBlock = (r.news || []).length
? `News the committee weighed ` +
r.news.map(n => `${sentDot(n.sentiment)} ${esc(n.headline)}` +
(n.sentiment ? ` ${esc(n.sentiment)} ` : "") + `
`).join("")
: "";
const valBits = [];
if (r.intrinsic_low && r.intrinsic_high) valBits.push(`bear–bull range ${Number(r.intrinsic_low).toFixed(0)}–${Number(r.intrinsic_high).toFixed(0)}`);
if (r.implied_growth !== null && r.implied_growth !== undefined)
valBits.push(`today's price assumes ${r.implied_growth >= 0.6 ? "over 60" : (r.implied_growth * 100).toFixed(0)}%/yr earnings growth`);
if (r.valuation_model) valBits.push(`${esc(r.valuation_model)} `);
const lotsHtml = (r.lots || []).length
? `Lots (average cost ${Number(r.entry).toFixed(2)}) ` +
r.lots.map(l => `${l.side === "sell" ? "sold" : "bought"} ${l.qty ?? "?"} @ ${Number(l.entry).toFixed(2)}${l.bought ? " on " + esc(l.bought) : ""}`).join(" · ") +
(r.lot_note ? ` — ${esc(r.lot_note)} ` : "") + " "
: "";
const peer = r.peer || {};
const peerNames = { n_earnings_yield: "earnings yield", n_evebitda: "EV/EBITDA value", n_roe: "ROE", n_netmargin: "margin", n_mom_12_1: "momentum" };
const peerBits = Object.entries(peer).map(([k, v]) => `${peerNames[k] || k} ${v}th`);
const memo = r.memo || null;
const memoHtml = memo ? `Investment memo ` +
(memo.thesis ? `Thesis: ${esc(memo.thesis)} ` : "") +
(memo.debate ? `The debate: ${esc(memo.debate)} ` : "") +
(memo.change_mind ? `What would change our mind: ${esc(memo.change_mind)} ` : "") : "";
const a = r.asymmetry, sz = r.sizing;
const asymHtml = a ? `Payoff asymmetry (DCF-implied, 12–18 months${a.gated === false ? " · growth-stage name: shown, not used to gate the action" : ""}) ` +
`bear ${pct(a.bear)} (${Math.round(a.p.bear * 100)}%) · base ${pct(a.base)} (${Math.round(a.p.base * 100)}%) · bull ${pct(a.bull)} (${Math.round(a.p.bull * 100)}%)` +
` → expected ${pct(a.expected_return)} , upside:downside ${a.up_down_ratio}:1 ` +
`${a.clears_hurdle ? "clears the 2:1 / +8% hurdle" : "below the 2:1 / +8% hurdle"} ` +
(sz ? `Sizing guardrail: ${sz.half_kelly > 0 ? `half-Kelly ${pct(sz.half_kelly, false)} of the book (cap ${pct(sz.cap, false)})` : "no addition justified at this payoff"} vs ${pct(sz.current, false)} held · volatility ${pct(sz.vol, false)} ` : "") : "";
const us = r.us || null;
let usHtml = "";
if (us) {
const e = us.econ || {}, pz = us.piotroski, vh = us.valuation_hist || {}, er = us.earnings || {}, ins = us.insiders || {}, inst = us.institutions || {};
const f = (v, d = 1) => (v === null || v === undefined) ? null : Number(v).toFixed(d);
const chips = [];
if (pz) chips.push(`Piotroski ${pz.score}/9 `);
if (us.altman_z !== null && us.altman_z !== undefined) chips.push(`Altman Z ${us.altman_z} (${us.altman_z > 2.99 ? "safe" : us.altman_z >= 1.8 ? "grey" : "distress"}) `);
if (us.beneish_m !== null && us.beneish_m !== undefined) chips.push(`Beneish M ${us.beneish_m} (${us.beneish_m < -1.78 ? "clean" : "warning"}) `);
if (e.accruals !== undefined && e.accruals !== null) chips.push(`accruals ${(e.accruals * 100).toFixed(1)}% of assets`);
if (e.cash_conversion !== undefined && e.cash_conversion !== null) chips.push(`FCF/NI ${f(e.cash_conversion, 2)}`);
const econ = [];
if (e.roic !== null && e.roic !== undefined) econ.push(`ROIC ${pct(e.roic, false)}`);
if (e.incremental_roic_5y !== undefined && e.incremental_roic_5y !== null) econ.push(`incremental ROIC (5y) ${pct(e.incremental_roic_5y, false)}`);
else if (e.capital_light) econ.push(`capital-light growth`);
if (e.owner_earnings_yield !== undefined && e.owner_earnings_yield !== null) econ.push(`owner-earnings yield ${pct(e.owner_earnings_yield, false)}`);
if (e.fcf_yield !== undefined && e.fcf_yield !== null) econ.push(`FCF yield ${pct(e.fcf_yield, false)}`);
if (e.greenblatt_ey !== undefined && e.greenblatt_ey !== null) econ.push(`EBIT/EV ${pct(e.greenblatt_ey, false)}`);
if (e.peg_3y !== undefined && e.peg_3y !== null) econ.push(`PEG ${e.peg_3y}`);
if (e.graham_number) econ.push(`Graham number ${e.graham_number}`);
if (e.rule_of_40 !== undefined && e.rule_of_40 !== null) econ.push(`Rule of 40: ${e.rule_of_40}`);
const cap = [];
if (e.buyback_yield_1y !== undefined && e.buyback_yield_1y !== null) cap.push(`buyback yield ${pct(e.buyback_yield_1y)}`);
if (e.share_change_5y !== undefined && e.share_change_5y !== null) cap.push(`shares 5y ${pct(e.share_change_5y)}`);
if (e.sbc_pct_rev !== undefined && e.sbc_pct_rev !== null) cap.push(`SBC ${pct(e.sbc_pct_rev, false)} of revenue`);
if (e.fcf_payout !== undefined && e.fcf_payout !== null) cap.push(`FCF payout ${pct(e.fcf_payout, false)}`);
if (e.dividend_streak_years) cap.push(`dividend raised ${e.dividend_streak_years} years running`);
const val = Object.entries(vh).filter(([k, v]) => v && typeof v === "object").map(([k, v]) => `${k.toUpperCase()} ${v.now} (${v.pctile}th pctile of own ${vh.years}y, median ${v.median}) `);
const mom = [];
if (er.n_q) mom.push(`beat ${er.beats_8q}/${er.n_q} quarters (avg surprise ${er.avg_surprise_pct > 0 ? "+" : ""}${er.avg_surprise_pct}%)`);
if (er.eps_revision_90d_pct !== undefined && er.eps_revision_90d_pct !== null) mom.push(`FY EPS estimate ${er.eps_revision_90d_pct > 0 ? "+" : ""}${er.eps_revision_90d_pct}% in 90d`);
if (er.revisions_30d) mom.push(`revisions 30d ${er.revisions_30d.up} up / ${er.revisions_30d.down} down`);
const own = [];
if (ins.months) own.push(`insiders 6m: bought $${(ins.buy_usd / 1e6).toFixed(1)}M (${ins.n_buyers}) / sold $${(ins.sell_usd / 1e6).toFixed(1)}M (${ins.n_sellers})`);
if (inst.holders_change !== undefined && inst.holders_change !== null) own.push(`13F holders ${inst.holders_change > 0 ? "+" : ""}${inst.holders_change} q/q${inst.shares_change_pct !== undefined && inst.shares_change_pct !== null ? `, shares held ${inst.shares_change_pct > 0 ? "+" : ""}${inst.shares_change_pct}%` : ""}`);
usHtml = `Deep fundamentals (FY ${esc(us.fiscal_year || "")}${e.lynch_category ? " · Lynch: " + esc(e.lynch_category) : ""}) ` +
(chips.length ? `Forensics: ${chips.join(" · ")} ` : "") +
(econ.length ? `Economics: ${econ.join(" · ")} ` : "") +
(cap.length ? `Capital allocation: ${cap.join(" · ")} ` : "") +
(val.length ? `Vs own history: ${val.join(" · ")} ` : "") +
(mom.length ? `Earnings momentum: ${mom.join(" · ")} ` : "") +
(own.length ? `Ownership: ${own.join(" · ")} ` : "") +
((us.flags || []).length ? `Forensic flags: ${esc(us.flags.join("; "))} ` : "") +
((us.sell_triggers || []).length ? `Sell triggers — what would break the thesis ` + us.sell_triggers.map(t => `${esc(t)} `).join("") : "");
}
let right = `
Strengths ` +
(r.strengths.length ? r.strengths.map(s => `${esc(s)} `).join("") : "— ") +
`Risks ` +
(r.risks.length ? r.risks.map(s => `${esc(s)} `).join("") : "— ") +
(valBits.length ? `Valuation ${valBits.join(" · ")} ` : "") +
memoHtml + asymHtml + usHtml +
lotsHtml +
(peerBits.length ? `Peer percentiles (${esc(r.peer_universe || "US")} universe) ${peerBits.join(" · ")} ` : "") +
(r.tax_note ? `Tax ${esc(r.tax_note)} ` : "") +
(r.flags.length ? `Guards ${esc(r.flags.join(", "))} ` : "") +
(r.data_flags && r.data_flags.length ? `Data quality ${esc(r.data_flags.join("; "))} ` : "") +
(r.data_source ? `Fundamentals: ${esc(r.data_source)}${r.fund_asof ? " as of " + esc(r.fund_asof) : ""}${r.price_asof ? " · price " + esc(r.price_asof) : ""} ` : "") +
newsBlock +
``;
return `${left}${right}
`;
}
function renderConstruction(plan) {
const box = $("#plan-construction"); if (!box) return; box.innerHTML = "";
const rc = plan.risk_contributions || {};
let html = "";
if ((rc.top || []).length) {
html += `Where your risk actually sits ` +
rc.top.map(([t, p]) => `${esc(t)} ${pct(p, false)} of risk` +
(rc.contrib && rc.contrib[t] ? ` (${pct(rc.contrib[t].weight, false)} of money) ` : "")).join(" · ") +
(rc.portfolio_vol ? ` · portfolio volatility ${pct(rc.portfolio_vol, false)} ` : "") + `
`;
}
const picks = plan.picks || [];
if (picks.length) {
html += `Adds chosen jointly (each given the one before it) ` +
picks.map(p => p.variance_reduction === null || p.variance_reduction === undefined
? `${p.order}. ${esc(p.ticker)} ${esc(p.reason || "")}`
: `${p.order}. ${esc(p.ticker)} cuts portfolio variance ${pct(p.variance_reduction, false)} (vol ${pct(p.vol_before, false)} → ${pct(p.vol_after, false)})`).join(" · ") + `
`;
}
const ra = plan.risk_contributions_after || {};
if ((ra.top || []).length) {
html += `Where the risk sits after the trade ` +
ra.top.map(([t, p]) => `${esc(t)} ${pct(p, false)} of risk`).join(" · ") +
(ra.portfolio_vol ? ` · portfolio volatility ${pct(ra.portfolio_vol, false)} ` : "") + `
`;
}
const s12 = plan.scenarios_12m || {}; const c12 = s12.current || {}; const p12 = s12.planned || {};
if (c12.base !== undefined && c12.base !== null) {
html += `Valuation risk/reward — where the book would trade if every holding converged to its bear / base / bull value Bear Base Bull Covered ` +
`Today ${pct(c12.bear)} ${pct(c12.base)} ${pct(c12.bull)} ${pct(c12.coverage, false)} ` +
(p12.base !== undefined && p12.base !== null ? `After the plan ${pct(p12.bear)} ${pct(p12.base)} ${pct(p12.bull)} ${pct(p12.coverage, false)} ` : "") +
`
`;
}
if ((plan.tax_plan || []).length) {
html += `Tax plan — how the sells are executed ` +
plan.tax_plan.map(x => `${esc(x.ticker)} ${esc(x.note)} `).join("") + ` `;
}
if ((plan.watchlist || []).length) {
html += `Watchlist — the bench, with entry conditions ` +
plan.watchlist.map(x => `${esc(x.ticker)} ${esc(x.name || "")} — ${esc(x.condition)} (${esc(x.why || "")}) `).join("") + ` `;
}
if ((plan.catalysts || []).length) {
html += `Catalysts — next 90 days ` +
plan.catalysts.map(x => `${esc(x.ticker)} ${esc(x.event)} ${esc(x.date)}`).join(" · ") + `
`;
}
const sc = plan.scenarios || {}; const pt = plan.post_trade || {}; const cur = pt.current || {};
const names = Object.keys(sc);
if (names.length) {
html += `Three ways to get there Scenario Turnover Est. tax Volatility Worst stress replay Effective positions Est. health ` +
`Today – – ${cur.vol ? pct(cur.vol, false) : "–"} ${worstStress(cur)} ${cur.effective_n ?? "–"} ${cur.est_health ?? "–"} ` +
names.map(n => { const s = sc[n], p = pt[n] || {}; return `${esc(s.label)}${s.tolerance_met === true ? ' within your loss tolerance ' : s.tolerance_met === false ? ' loss tolerance not reachable within these bounds ' : ""} ${pct(s.turnover, false)} ${s.est_tax_usd !== null && s.est_tax_usd !== undefined ? "$" + Number(s.est_tax_usd).toLocaleString() : pct(s.est_tax_pct_of_book, false) + " of book"} ${p.vol ? pct(p.vol, false) : "–"} ${worstStress(p)} ${p.effective_n ?? "–"} ${p.est_health ?? "–"} `; }).join("") +
`
The order plan below follows the full rebalance; the tax-light and new-cash routes show what you give up by trading less.
`;
}
box.innerHTML = html;
}
function worstStress(blk) {
const st = (blk && blk.stress) || []; if (!st.length) return "–";
const w = st.reduce((a, b) => (b.portfolio < a.portfolio ? b : a));
return `${pct(w.portfolio)} (${esc(w.name)}) `;
}
function renderRejected(R) {
const rej = R.rejected_candidates || []; const sec = $("#rejected-section"); if (!sec) return;
sec.hidden = !rej.length; const body = $("#rejected-body"); body.innerHTML = "";
for (const r of rej) body.appendChild(el("div", "rej", `${esc(r.ticker)} ${esc(r.stage)} — ${esc(r.reason)}`));
}
function renderRisk(R) {
const rk = R.risk || {}; const sec = $("#risk-section"); const body = $("#risk-body");
const bn = (R.benchmark && R.benchmark.name) || "benchmark";
let html = "";
if ((rk.stress || []).length) {
html += `Stress replay — what today's weights would have done Shock Window Portfolio ${esc(bn)} ` +
rk.stress.map(s => `${esc(s.name)} ${esc(s.window)} ${pct(s.portfolio)} ${s.benchmark === null || s.benchmark === undefined ? "–" : pct(s.benchmark)} `).join("") +
`
`;
}
const chips = [];
if (rk.var_1d_95 !== undefined && rk.var_1d_95 !== null)
chips.push(`Value at risk (95%) 1-day ${pct(rk.var_1d_95, false)} · 1-month ${pct(rk.var_1m_95, false)}` +
(rk.cvar_1d_95 ? ` · tail loss ${pct(rk.cvar_1d_95, false)}` : ""));
if (rk.tracking_error) chips.push(`Tracking error vs ${esc(bn)} ${pct(rk.tracking_error, false)}`);
if (rk.currency_exposure && Object.keys(rk.currency_exposure).length > 1)
chips.push(`Currency ` + Object.entries(rk.currency_exposure).map(([c, w]) => `${esc(c)} ${pct(w, false)}`).join(" · "));
const lt = rk.lookthrough || {};
if ((lt.top_names || []).length)
chips.push(`Fund look-through (constituents disclosed for ${pct(lt.coverage, false)} of fund weight): ` +
lt.top_names.slice(0, 6).map(([n, d, v, t]) => `${esc(n)} ${pct(t, false)}` + (d && v ? ` (direct ${pct(d, false)} + via funds ${pct(v, false)}) ` : "")).join(" · "));
const ft = rk.factor_tilts || {};
if (ft.tilts) chips.push(`Style tilts (${pct(ft.coverage, false)} of book): ` +
Object.entries(ft.tilts).map(([k, v]) => `${esc(k)} ${v >= 0 ? "+" : ""}${Number(v).toFixed(2)} `).join(" · "));
const fb = rk.factor_betas || {};
if (fb.betas) chips.push(`Measured factor betas (vs ${esc(bn)}, ${fb.n_days} days; market beta ${fb.market_beta}): ` +
Object.entries(fb.betas).map(([k, v]) => `${esc(k.replace("_", " "))} ${v >= 0 ? "+" : ""}${Number(v).toFixed(2)} `).join(" · "));
const br = rk.benchmark_relative || {};
if (br.upside_capture !== undefined || br.sortino !== undefined) {
const bits = [];
if (br.upside_capture !== undefined) bits.push(`upside capture ${Number(br.upside_capture).toFixed(2)}`);
if (br.downside_capture !== undefined) bits.push(`downside capture ${Number(br.downside_capture).toFixed(2)}`);
if (br.sortino !== undefined) bits.push(`Sortino ${br.sortino}`);
if (br.calmar !== undefined) bits.push(`Calmar ${br.calmar}`);
if (br.active_return_ann !== undefined) bits.push(`active return ${pct(br.active_return_ann)}/yr`);
chips.push(`Vs ${esc(bn)} ` + bits.join(" · "));
}
if ((rk.active_sectors || []).length)
chips.push(`Active sector bets vs ${esc(bn)} ` + rk.active_sectors.slice(0, 6).map(x => `${esc(x.sector)} ${x.active >= 0 ? "+" : ""}${(x.active * 100).toFixed(0)}pp (${pct(x.book, false)} vs ${pct(x.benchmark, false)}) `).join(" · "));
if ((rk.liquidity || []).length)
chips.push(`Liquidity ` + rk.liquidity.slice(0, 4).map(x => `${esc(x.ticker)} ≈ ${x.days_to_liquidate} days to exit`).join(" · "));
html += chips.map(c => `${c}
`).join("");
body.innerHTML = html;
sec.hidden = !html;
}
function ivBar(r) {
if (!r.intrinsic) return "";
const pts = [["Entry", r.entry, "var(--dim)"], ["Now", r.price, "var(--accent)"],
["Intrinsic", r.intrinsic, "var(--good)"]];
const vals = pts.map(p => p[1]);
const lo = Math.min(...vals) * 0.92, hi = Math.max(...vals) * 1.08;
const x = v => ((v - lo) / (hi - lo) * 100).toFixed(1);
return `Price vs value${r.lifecycle ? ` · ${esc(r.lifecycle)}` : ""}
` +
pts.map(([lab, v, c]) =>
`
${lab} ${Number(v).toFixed(0)}
`).join("") +
`
`;
}
function renderDonut(R) {
const svg = $("#donut"); svg.innerHTML = "";
const entries = Object.entries(R.sector_allocation);
let a0 = -Math.PI / 2;
const cx = 100, cy = 100, r1 = 58, r2 = 88;
entries.forEach(([s, w], i) => {
const a1 = a0 + w * 2 * Math.PI;
const large = (a1 - a0) > Math.PI ? 1 : 0;
const p = (a, r) => `${cx + r * Math.cos(a)} ${cy + r * Math.sin(a)}`;
const d = `M ${p(a0, r2)} A ${r2} ${r2} 0 ${large} 1 ${p(a1, r2)} L ${p(a1, r1)} A ${r1} ${r1} 0 ${large} 0 ${p(a0, r1)} Z`;
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
path.setAttribute("d", d);
path.setAttribute("fill", SECTOR_COLORS[i % SECTOR_COLORS.length]);
path.setAttribute("opacity", "0.9");
svg.appendChild(path);
a0 = a1;
});
const legend = $("#donut-legend"); legend.innerHTML = "";
const [lo, hi] = R.sector_band;
entries.forEach(([s, w], i) => {
const over = w > hi ? ` above ${Math.round(hi * 100)}% band ` : "";
legend.appendChild(el("div", null,
` ${esc(s)}${pct(w, false)} ${over}`));
});
}
function renderWeights(R) {
const wrap = $("#weight-bars"); wrap.innerHTML = "";
const cap = R.name_cap;
const maxW = Math.max(...R.holdings.map(h => h.weight), cap) * 1.15;
for (const h of R.holdings) {
const wb = el("div", "wb");
const over = h.weight > cap;
wb.innerHTML = `${esc(h.ticker)}
${pct(h.weight, false)} `;
wrap.appendChild(wb);
}
$("#cap-hint").textContent = `Red line = ${Math.round(cap * 100)}% healthy single-position cap.`;
}
$("#rerun").onclick = () => show("input");