/* 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

    ` + `` + (p12.base !== undefined && p12.base !== null ? `` : "") + `
    BearBaseBullCovered
    Today${pct(c12.bear)}${pct(c12.base)}${pct(c12.bull)}${pct(c12.coverage, false)}
    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

    `; } if ((plan.watchlist || []).length) { html += `

    Watchlist — the bench, with entry conditions

    `; } 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

    ` + `` + names.map(n => { const s = sc[n], p = pt[n] || {}; return ``; }).join("") + `
    ScenarioTurnoverEst. taxVolatilityWorst stress replayEffective positionsEst. health
    Today––${cur.vol ? pct(cur.vol, false) : "–"}${worstStress(cur)}${cur.effective_n ?? "–"}${cur.est_health ?? "–"}
    ${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 ?? "–"}
    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

    ` + rk.stress.map(s => ``).join("") + `
    ShockWindowPortfolio${esc(bn)}
    ${esc(s.name)}${esc(s.window)}${pct(s.portfolio)}${s.benchmark === null || s.benchmark === undefined ? "–" : pct(s.benchmark)}
    `; } 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");