Download static/app.js from s-ttp/vitals: direct link, hf CLI and curl.
- Browser
- Download file 54.9 kB
-
https://huggingface.co/spaces/s-ttp/vitals/resolve/main/static/app.js
- Command line
-
hf download hf://spaces/s-ttp/vitals/static/app.js
-
curl -L -o app.js https://huggingface.co/spaces/s-ttp/vitals/resolve/main/static/app.js
54.9 kB
| /* Portfolio Analyst — input → SSE progress → results summary. No frameworks. */ | |
| ; | |
| 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 = ` | |
| <td><input class="q" placeholder="NVDA or SK Hynix" value="${esc(query)}"></td> | |
| <td><input class="e" type="number" min="0" step="any" placeholder="e.g. 450" value="${esc(entry)}"></td> | |
| <td><input class="n" type="number" step="any" placeholder="—" title="Quantity — negative for a sale" value="${esc(qty)}"></td> | |
| <td><input class="b" type="date" title="Purchase date — unlocks short- vs long-term tax treatment" value="${esc(bought)}"></td> | |
| <td class="resolved-cell"><div class="resolved"></div></td> | |
| <td><button class="rm" title="Remove">×</button></td>`; | |
| 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 = `<b>${esc(r.symbol)}</b> ${esc(r.name || "")} · ${Number(r.price).toFixed(2)} ${esc(r.currency)}`; | |
| if (r.pl_pct !== undefined) | |
| html += ` · <span class="${r.pl_pct >= 0 ? "pl-up" : "pl-dn"}">${pct(r.pl_pct)} since entry</span>`; | |
| 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 = `<div class="imp-label">${esc(label)}</div> | |
| <div class="mini-track"><div class="mini-fill"></div></div>`; | |
| } | |
| $("#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}): <b class="${v.avg_return >= 0 ? "up" : "dn"}">${pct(v.avg_return)}</b> 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 => `<span class="tk">${c}</span>`).join("<span class='tk-sep'>·</span>"); | |
| $("#ticker").innerHTML = html + "<span class='tk-sep'>·</span>" + 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 = `<div class="track-head">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` : "") + `</div>`; | |
| for (const [action, v] of Object.entries(t.by_action)) { | |
| const cls = v.avg_return >= 0 ? "up" : "dn"; | |
| html += `<span class="track-pill">${esc(action)} (${v.n}): <b class="${cls}">${pct(v.avg_return)}</b></span>`; | |
| } | |
| tr.innerHTML = html; | |
| } else { | |
| tr.innerHTML = `<div class="track-head">Track record starts building from your first saved verdict.</div>`; | |
| } | |
| 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] || "•"} <span class="alert-date">${esc(a.date || "")}</span> ${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 <b>${esc(d.code)}</b> — the track record grows.` | |
| : `Saved. Your case ID is <b class="code-big">${esc(d.code)}</b> — 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 <b>${esc(d.code)}</b>.`; } }) | |
| .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 `<div class="pillar" title="${esc(p.note || "")}"><span class="pillar-name">${esc(p.label)} <span class="dim">${Math.round(p.weight * 100)}%</span></span>` + | |
| `<span class="pillar-bar"><span style="width:${Math.max(2, p.score)}%;background:${col}"></span></span><span class="pillar-num">${Math.round(p.score)}</span></div>`; | |
| }).join("") + (hb.tolerance_penalty ? `<div class="hint">−${Math.abs(hb.tolerance_penalty)} for breaching your stated loss tolerance</div>` : ""); | |
| } | |
| $("#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 <span class="${R.total_pl_pct >= 0 ? "up" : "dn"}">${pct(R.total_pl_pct)}</span>`)); | |
| $("#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", | |
| `<span class="dot ${esc(h.severity)}"></span><b>${esc(h.title)}</b> | |
| <p>${esc(h.detail)}</p><span class="lens">${esc(h.lens || "")} lens</span>`)); | |
| } | |
| // 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 = ` | |
| <td class="tick"><b>${esc(r.ticker)}</b><span>${esc(r.name)}</span></td> | |
| <td>${pct(r.weight, false)}</td> | |
| <td class="${r.pl_pct >= 0 ? "up" : "dn"}">${pct(r.pl_pct)}${r.entry_note ? ` <span class="sig" title="${esc(r.entry_note)}">${r.entry_note.includes("kept") ? "split: kept as entered" : "split-adjusted"}</span>` : ""}${r.n_lots > 1 | |
| ? ` <span class="sig" title="${esc(r.lot_note || "average cost of " + r.n_lots + " lots")}">${r.n_lots} lots · avg ${Number(r.entry).toFixed(2)}</span>` : ""}</td> | |
| <td>${r.upside === null || r.upside === undefined | |
| ? (r.dcf_note ? `<span class="dim" title="DCF not meaningful">${esc(r.dcf_note)}</span>` : "–") | |
| : `<span class="${r.upside >= 0 ? "up" : "dn"}">${pct(r.upside)}</span>`}</td> | |
| <td><span class="badge ${cls[r.action]}">${esc(r.action)}</span>${r.watch ? ' <span class="sig" title="weak backing and a negative expected payoff — first source of funds">watch</span>' : ""}</td> | |
| <td class="why">${esc(r.rationale)}</td>`; | |
| const dtr = el("tr", "detail"); dtr.hidden = true; | |
| dtr.innerHTML = `<td colspan="6">${detailHTML(r)}</td>`; | |
| 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", | |
| `<span class="st ${c.status}">${c.status}</span><div><b>${esc(c.check)}</b><p>${esc(c.detail)}</p></div>`)); | |
| const st = R.strategist || {}; const sr = $("#strategy-read"); sr.innerHTML = ""; | |
| if (st.strategy_read) sr.innerHTML = | |
| `<b>Strategy read:</b> ${esc(st.strategy_read)}` + | |
| (st.style_tilt ? ` <b>Style:</b> ${esc(st.style_tilt)}.` : "") + | |
| (st.gaps && st.gaps.length ? ` <b>Missing exposure:</b> ${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", `<b>What this portfolio needs:</b> ${esc(needLabels.join(" · "))}`)); | |
| for (const g of ((R.plan || {}).gap_search || [])) { | |
| av.appendChild(el("div", "needs-line dim", `<b>${esc(g.sector)}:</b> ${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 => | |
| `<span class="sig">${esc(s.includes("smart") ? "🐋 smart money" : s.includes("quant") ? "🧮 quant rank" : s.includes("local") ? "📍 local market" : "🇺🇸 US-listed")}</span>`).join(" "); | |
| const fit = a.fit || {}; | |
| const fixes = (fit.fixes || []).map(f => `<span class="fix">✓ ${esc(f)}</span>`).join(" ") + | |
| (fit.cautions || []).map(f => `<span class="caution">⚠ ${esc(f)}</span>`).join(" "); | |
| const com = a.committee; | |
| const comLine = com ? `<div class="committee">Committee ${com.conviction}/100 on adding it` + | |
| ((com.risks || []).length ? ` · top risk: ${esc(com.risks[0])}` : "") + `</div>` : ""; | |
| av.appendChild(el("div", "alt", `<b>${esc(a.ticker)}</b> ${tags}<p>${esc(a.reason)}</p>` + | |
| (fixes ? `<div class="fixes">${fixes}</div>` : "") + 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 = ` | |
| <td class="tick"><b>${esc(r.ticker)}</b>${r.is_new ? ' <span class="badge buy">New</span>' : ""}</td> | |
| <td class="why">${esc(r.sector)}</td> | |
| <td>${pct(r.current_w, false)}</td> | |
| <td><b>${pct(r.target_w, false)}</b></td> | |
| <td class="${dCls}">${d > 0 ? "▲" : d < 0 ? "▼" : "·"} ${pct(Math.abs(d), false)}</td>`; | |
| 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", | |
| `<span class="move-n">${i + 1}</span><div><b>${esc(m.title)}</b><p>${esc(m.detail)}</p></div>`)); | |
| }); | |
| const extraBox = $("#plan-extra"); if (extraBox) { | |
| let x = ""; | |
| if (plan.do_nothing) x += `<div class="move"><span class="move-n">✓</span><div><b>Do nothing</b><p>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.</p></div></div>`; | |
| for (const h of (plan.harvest_pairs || [])) { | |
| x += `<div class="move"><span class="move-n">$</span><div><b>Tax-loss harvest: sell ${esc(h.sell)}, buy ${esc(h.buy)}</b><p>${esc(h.note)} <span class="dim">(${esc(h.buy_name)}, correlation ${h.correlation})</span></p></div></div>`; | |
| } | |
| const pm = R.premortem; | |
| if (pm && pm.story) x += `<div class="move premortem"><span class="move-n">⚠</span><div><b>Pre-mortem — how ${esc(pm.ticker)} loses 40% from here</b><p>${esc(pm.story)}</p></div></div>`; | |
| 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 ? ` <span class="sig" title="${esc(p.tax_note)}">wash-sale rule</span>` : ""; | |
| 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", | |
| `<div class="pair-head"><span class="badge sell">${esc(p.out)}</span> ➜ ` + | |
| `<span class="badge buy">${esc(p.in)}</span> <span class="pair-sec">${esc(p.sector || "")}` + | |
| `${p.same_sector ? " · same sector" : ""}</span></div>` + | |
| `<div class="pair-cmp"><span class="dn">${esc(p.out)}: ${esc(line(o))}</span>` + | |
| `<span class="up">${esc(p.in)}: ${esc(line(n))}</span></div>`)); | |
| } | |
| // 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 `<div class="ol"><span class="ol-lab">${lab}</span><span>${f(a)} <span class="ol-arrow">➜</span> <b>${f(b)}</b></span></div>`; | |
| }; | |
| 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 = `<td><span class="badge ${o.side === "SELL" ? "sell" : "buy"}">${o.side}</span></td> | |
| <td><b>${esc(o.ticker)}</b>${o.is_new ? ' <span class="sig">new</span>' : ""}</td> | |
| <td>${pct(Math.abs(o.delta_pct), false)} of portfolio</td> | |
| <td>${o.amount_usd ? "$" + Number(o.amount_usd).toLocaleString() + | |
| (o.shares ? ` · ~${o.shares} sh` : "") : "—"}${o.cost_note ? ` <span class="sig">${esc(o.cost_note)}</span>` : ""}</td>`; | |
| 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] || "•"} <b>${esc(t.ticker)}</b> — <i>${esc(t.disposition)}</i>: ${esc(t.note)}`)); | |
| } | |
| } | |
| function sentDot(s) { | |
| const l = String(s || "").toLowerCase(); | |
| if (l.includes("bull")) return '<span class="dot good"></span>'; | |
| if (l.includes("bear")) return '<span class="dot bad"></span>'; | |
| return '<span class="dot" style="background:var(--dim)"></span>'; | |
| } | |
| 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 = `<div class="detail"><h4>Committee conviction</h4> | |
| <div class="conv-meter"><div class="conv-fill" style="width:${r.conviction}%;background:${convColor}"></div></div> | |
| <li style="list-style:none;margin-left:0">${r.conviction}/100 median across ${r.n_lenses} lenses` + | |
| (r.dispersion >= 30 ? ` — <span class="dn">wide disagreement (${r.dispersion} pts)</span>` : "") + `</li>`; | |
| const scores = r.lens_scores || {}; | |
| for (const k of Object.keys(lensNames)) if (scores[k] !== undefined) | |
| left += `<li style="list-style:none;margin-left:0">${lensNames[k]}: ${Math.round(scores[k])}</li>`; | |
| left += ivBar(r) + `</div>`; | |
| const newsBlock = (r.news || []).length | |
| ? `<h4 style="margin-top:12px">News the committee weighed</h4>` + | |
| r.news.map(n => `<div class="news-row">${sentDot(n.sentiment)} ${esc(n.headline)}` + | |
| (n.sentiment ? ` <span class="news-sent">${esc(n.sentiment)}</span>` : "") + `</div>`).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 <b>${r.implied_growth >= 0.6 ? "over 60" : (r.implied_growth * 100).toFixed(0)}%/yr</b> earnings growth`); | |
| if (r.valuation_model) valBits.push(`<span class="dim">${esc(r.valuation_model)}</span>`); | |
| const lotsHtml = (r.lots || []).length | |
| ? `<h4 style="margin-top:12px">Lots (average cost ${Number(r.entry).toFixed(2)})</h4><li>` + | |
| 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 ? ` <span class="dim">— ${esc(r.lot_note)}</span>` : "") + "</li>" | |
| : ""; | |
| 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 ? `<h4 style="margin-top:12px">Investment memo</h4>` + | |
| (memo.thesis ? `<li><b>Thesis:</b> ${esc(memo.thesis)}</li>` : "") + | |
| (memo.debate ? `<li><b>The debate:</b> ${esc(memo.debate)}</li>` : "") + | |
| (memo.change_mind ? `<li><b>What would change our mind:</b> ${esc(memo.change_mind)}</li>` : "") : ""; | |
| const a = r.asymmetry, sz = r.sizing; | |
| const asymHtml = a ? `<h4 style="margin-top:12px">Payoff asymmetry (DCF-implied, 12–18 months${a.gated === false ? " · growth-stage name: shown, not used to gate the action" : ""})</h4><li>` + | |
| `bear <span class="dn">${pct(a.bear)}</span> (${Math.round(a.p.bear * 100)}%) · base ${pct(a.base)} (${Math.round(a.p.base * 100)}%) · bull <span class="up">${pct(a.bull)}</span> (${Math.round(a.p.bull * 100)}%)` + | |
| ` → expected <b class="${a.expected_return >= 0 ? "up" : "dn"}">${pct(a.expected_return)}</b>, upside:downside <b>${a.up_down_ratio}:1</b> ` + | |
| `<span class="sig">${a.clears_hurdle ? "clears the 2:1 / +8% hurdle" : "below the 2:1 / +8% hurdle"}</span></li>` + | |
| (sz ? `<li class="dim">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)}</li>` : "") : ""; | |
| 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 <b>${pz.score}/9</b>`); | |
| if (us.altman_z !== null && us.altman_z !== undefined) chips.push(`Altman Z <b>${us.altman_z}</b> <span class="dim">(${us.altman_z > 2.99 ? "safe" : us.altman_z >= 1.8 ? "grey" : "distress"})</span>`); | |
| if (us.beneish_m !== null && us.beneish_m !== undefined) chips.push(`Beneish M <b>${us.beneish_m}</b> <span class="dim">(${us.beneish_m < -1.78 ? "clean" : "warning"})</span>`); | |
| 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} <span class="dim">(${v.pctile}th pctile of own ${vh.years}y, median ${v.median})</span>`); | |
| 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 = `<h4 style="margin-top:12px">Deep fundamentals <span class="dim">(FY ${esc(us.fiscal_year || "")}${e.lynch_category ? " · Lynch: " + esc(e.lynch_category) : ""})</span></h4>` + | |
| (chips.length ? `<li><b>Forensics:</b> ${chips.join(" · ")}</li>` : "") + | |
| (econ.length ? `<li><b>Economics:</b> ${econ.join(" · ")}</li>` : "") + | |
| (cap.length ? `<li><b>Capital allocation:</b> ${cap.join(" · ")}</li>` : "") + | |
| (val.length ? `<li><b>Vs own history:</b> ${val.join(" · ")}</li>` : "") + | |
| (mom.length ? `<li><b>Earnings momentum:</b> ${mom.join(" · ")}</li>` : "") + | |
| (own.length ? `<li><b>Ownership:</b> ${own.join(" · ")}</li>` : "") + | |
| ((us.flags || []).length ? `<li class="dn"><b>Forensic flags:</b> ${esc(us.flags.join("; "))}</li>` : "") + | |
| ((us.sell_triggers || []).length ? `<h4 style="margin-top:12px">Sell triggers — what would break the thesis</h4>` + us.sell_triggers.map(t => `<li>${esc(t)}</li>`).join("") : ""); | |
| } | |
| let right = `<div class="detail"><h4>Strengths</h4>` + | |
| (r.strengths.length ? r.strengths.map(s => `<li>${esc(s)}</li>`).join("") : "<li>—</li>") + | |
| `<h4 style="margin-top:12px">Risks</h4>` + | |
| (r.risks.length ? r.risks.map(s => `<li>${esc(s)}</li>`).join("") : "<li>—</li>") + | |
| (valBits.length ? `<h4 style="margin-top:12px">Valuation</h4><li>${valBits.join(" · ")}</li>` : "") + | |
| memoHtml + asymHtml + usHtml + | |
| lotsHtml + | |
| (peerBits.length ? `<h4 style="margin-top:12px">Peer percentiles (${esc(r.peer_universe || "US")} universe)</h4><li>${peerBits.join(" · ")}</li>` : "") + | |
| (r.tax_note ? `<h4 style="margin-top:12px">Tax</h4><li>${esc(r.tax_note)}</li>` : "") + | |
| (r.flags.length ? `<h4 style="margin-top:12px">Guards</h4><li>${esc(r.flags.join(", "))}</li>` : "") + | |
| (r.data_flags && r.data_flags.length ? `<h4 style="margin-top:12px">Data quality</h4><li class="dim">${esc(r.data_flags.join("; "))}</li>` : "") + | |
| (r.data_source ? `<li class="dim" style="margin-top:8px">Fundamentals: ${esc(r.data_source)}${r.fund_asof ? " as of " + esc(r.fund_asof) : ""}${r.price_asof ? " · price " + esc(r.price_asof) : ""}</li>` : "") + | |
| newsBlock + | |
| `</div>`; | |
| return `<div class="detail-grid">${left}${right}</div>`; | |
| } | |
| function renderConstruction(plan) { | |
| const box = $("#plan-construction"); if (!box) return; box.innerHTML = ""; | |
| const rc = plan.risk_contributions || {}; | |
| let html = ""; | |
| if ((rc.top || []).length) { | |
| html += `<h4>Where your risk actually sits</h4><div class="risk-line">` + | |
| rc.top.map(([t, p]) => `<b>${esc(t)}</b> ${pct(p, false)} of risk` + | |
| (rc.contrib && rc.contrib[t] ? ` <span class="dim">(${pct(rc.contrib[t].weight, false)} of money)</span>` : "")).join(" · ") + | |
| (rc.portfolio_vol ? ` <span class="dim">· portfolio volatility ${pct(rc.portfolio_vol, false)}</span>` : "") + `</div>`; | |
| } | |
| const picks = plan.picks || []; | |
| if (picks.length) { | |
| html += `<h4>Adds chosen jointly (each given the one before it)</h4><div class="risk-line">` + | |
| picks.map(p => p.variance_reduction === null || p.variance_reduction === undefined | |
| ? `<b>${p.order}. ${esc(p.ticker)}</b> ${esc(p.reason || "")}` | |
| : `<b>${p.order}. ${esc(p.ticker)}</b> cuts portfolio variance ${pct(p.variance_reduction, false)} (vol ${pct(p.vol_before, false)} → ${pct(p.vol_after, false)})`).join(" · ") + `</div>`; | |
| } | |
| const ra = plan.risk_contributions_after || {}; | |
| if ((ra.top || []).length) { | |
| html += `<h4>Where the risk sits after the trade</h4><div class="risk-line">` + | |
| ra.top.map(([t, p]) => `<b>${esc(t)}</b> ${pct(p, false)} of risk`).join(" · ") + | |
| (ra.portfolio_vol ? ` <span class="dim">· portfolio volatility ${pct(ra.portfolio_vol, false)}</span>` : "") + `</div>`; | |
| } | |
| const s12 = plan.scenarios_12m || {}; const c12 = s12.current || {}; const p12 = s12.planned || {}; | |
| if (c12.base !== undefined && c12.base !== null) { | |
| html += `<h4>Valuation risk/reward — where the book would trade if every holding converged to its bear / base / bull value</h4><table class="mini-table"><thead><tr><th></th><th>Bear</th><th>Base</th><th>Bull</th><th>Covered</th></tr></thead><tbody>` + | |
| `<tr><td><b>Today</b></td><td>${pct(c12.bear)}</td><td>${pct(c12.base)}</td><td>${pct(c12.bull)}</td><td>${pct(c12.coverage, false)}</td></tr>` + | |
| (p12.base !== undefined && p12.base !== null ? `<tr><td><b>After the plan</b></td><td>${pct(p12.bear)}</td><td>${pct(p12.base)}</td><td>${pct(p12.bull)}</td><td>${pct(p12.coverage, false)}</td></tr>` : "") + | |
| `</tbody></table>`; | |
| } | |
| if ((plan.tax_plan || []).length) { | |
| html += `<h4>Tax plan — how the sells are executed</h4><ul class="plain">` + | |
| plan.tax_plan.map(x => `<li><b>${esc(x.ticker)}</b> ${esc(x.note)}</li>`).join("") + `</ul>`; | |
| } | |
| if ((plan.watchlist || []).length) { | |
| html += `<h4>Watchlist — the bench, with entry conditions</h4><ul class="plain">` + | |
| plan.watchlist.map(x => `<li><b>${esc(x.ticker)}</b> ${esc(x.name || "")} — <i>${esc(x.condition)}</i> <span class="dim">(${esc(x.why || "")})</span></li>`).join("") + `</ul>`; | |
| } | |
| if ((plan.catalysts || []).length) { | |
| html += `<h4>Catalysts — next 90 days</h4><div class="risk-line">` + | |
| plan.catalysts.map(x => `<b>${esc(x.ticker)}</b> ${esc(x.event)} ${esc(x.date)}`).join(" · ") + `</div>`; | |
| } | |
| const sc = plan.scenarios || {}; const pt = plan.post_trade || {}; const cur = pt.current || {}; | |
| const names = Object.keys(sc); | |
| if (names.length) { | |
| html += `<h4>Three ways to get there</h4><table class="mini-table"><thead><tr><th>Scenario</th><th>Turnover</th><th>Est. tax</th><th>Volatility</th><th>Worst stress replay</th><th>Effective positions</th><th>Est. health</th></tr></thead><tbody>` + | |
| `<tr><td><b>Today</b></td><td>–</td><td>–</td><td>${cur.vol ? pct(cur.vol, false) : "–"}</td><td>${worstStress(cur)}</td><td>${cur.effective_n ?? "–"}</td><td>${cur.est_health ?? "–"}</td></tr>` + | |
| names.map(n => { const s = sc[n], p = pt[n] || {}; return `<tr><td>${esc(s.label)}${s.tolerance_met === true ? ' <span class="sig">within your loss tolerance</span>' : s.tolerance_met === false ? ' <span class="sig dn">loss tolerance not reachable within these bounds</span>' : ""}</td><td>${pct(s.turnover, false)}</td><td>${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"}</td><td>${p.vol ? pct(p.vol, false) : "–"}</td><td>${worstStress(p)}</td><td>${p.effective_n ?? "–"}</td><td><b>${p.est_health ?? "–"}</b></td></tr>`; }).join("") + | |
| `</tbody></table><div class="hint">The order plan below follows the full rebalance; the tax-light and new-cash routes show what you give up by trading less.</div>`; | |
| } | |
| 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 `<span class="dn">${pct(w.portfolio)}</span> <span class="dim">(${esc(w.name)})</span>`; | |
| } | |
| 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", `<b>${esc(r.ticker)}</b> <span class="dim">${esc(r.stage)}</span> — ${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 += `<h4>Stress replay — what today's weights would have done</h4><table class="mini-table"><thead><tr><th>Shock</th><th>Window</th><th>Portfolio</th><th>${esc(bn)}</th></tr></thead><tbody>` + | |
| rk.stress.map(s => `<tr><td>${esc(s.name)}</td><td class="dim">${esc(s.window)}</td><td class="${s.portfolio >= 0 ? "up" : "dn"}">${pct(s.portfolio)}</td><td>${s.benchmark === null || s.benchmark === undefined ? "–" : pct(s.benchmark)}</td></tr>`).join("") + | |
| `</tbody></table>`; | |
| } | |
| const chips = []; | |
| if (rk.var_1d_95 !== undefined && rk.var_1d_95 !== null) | |
| chips.push(`<b>Value at risk (95%)</b> 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(`<b>Tracking error vs ${esc(bn)}</b> ${pct(rk.tracking_error, false)}`); | |
| if (rk.currency_exposure && Object.keys(rk.currency_exposure).length > 1) | |
| chips.push(`<b>Currency</b> ` + 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(`<b>Fund look-through</b> (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 ? ` <span class="dim">(direct ${pct(d, false)} + via funds ${pct(v, false)})</span>` : "")).join(" · ")); | |
| const ft = rk.factor_tilts || {}; | |
| if (ft.tilts) chips.push(`<b>Style tilts</b> (${pct(ft.coverage, false)} of book): ` + | |
| Object.entries(ft.tilts).map(([k, v]) => `${esc(k)} <span class="${v >= 0 ? "up" : "dn"}">${v >= 0 ? "+" : ""}${Number(v).toFixed(2)}</span>`).join(" · ")); | |
| const fb = rk.factor_betas || {}; | |
| if (fb.betas) chips.push(`<b>Measured factor betas</b> (vs ${esc(bn)}, ${fb.n_days} days; market beta ${fb.market_beta}): ` + | |
| Object.entries(fb.betas).map(([k, v]) => `${esc(k.replace("_", " "))} <span class="${v >= 0 ? "up" : "dn"}">${v >= 0 ? "+" : ""}${Number(v).toFixed(2)}</span>`).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(`<b>Vs ${esc(bn)}</b> ` + bits.join(" · ")); | |
| } | |
| if ((rk.active_sectors || []).length) | |
| chips.push(`<b>Active sector bets vs ${esc(bn)}</b> ` + rk.active_sectors.slice(0, 6).map(x => `${esc(x.sector)} <span class="${x.active >= 0 ? "up" : "dn"}">${x.active >= 0 ? "+" : ""}${(x.active * 100).toFixed(0)}pp</span> <span class="dim">(${pct(x.book, false)} vs ${pct(x.benchmark, false)})</span>`).join(" · ")); | |
| if ((rk.liquidity || []).length) | |
| chips.push(`<b>Liquidity</b> ` + rk.liquidity.slice(0, 4).map(x => `${esc(x.ticker)} ≈ ${x.days_to_liquidate} days to exit`).join(" · ")); | |
| html += chips.map(c => `<div class="risk-line">${c}</div>`).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 `<h4 style="margin-top:14px">Price vs value${r.lifecycle ? ` · ${esc(r.lifecycle)}` : ""}</h4> | |
| <div class="iv-bar"><div class="iv-track"></div>` + | |
| pts.map(([lab, v, c]) => | |
| `<div class="iv-mark" style="left:${x(v)}%;background:${c}"><span>${lab} ${Number(v).toFixed(0)}</span></div>`).join("") + | |
| `</div>`; | |
| } | |
| 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 ? ` <span class="over">above ${Math.round(hi * 100)}% band</span>` : ""; | |
| legend.appendChild(el("div", null, | |
| `<span class="sw" style="background:${SECTOR_COLORS[i % SECTOR_COLORS.length]}"></span>${esc(s)}<span class="pct">${pct(w, false)}</span>${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 = `<span>${esc(h.ticker)}</span> | |
| <div class="track"><div class="fill ${over ? "over" : ""}" style="width:${(h.weight / maxW * 100).toFixed(1)}%"></div> | |
| <div class="cap" style="left:${(cap / maxW * 100).toFixed(1)}%"></div></div> | |
| <span class="w">${pct(h.weight, false)}</span>`; | |
| wrap.appendChild(wb); | |
| } | |
| $("#cap-hint").textContent = `Red line = ${Math.round(cap * 100)}% healthy single-position cap.`; | |
| } | |
| $("#rerun").onclick = () => show("input"); | |