diff --git a/frontend/app.js b/frontend/app.js new file mode 100644 index 0000000..05799e1 --- /dev/null +++ b/frontend/app.js @@ -0,0 +1,1086 @@ +/** + * MarkoWizard — analytical workstation. + * + * Owns the control-rail state (universe, history window, risk-free rate) + * and every report section: the KPI band, the efficient-frontier chart, the + * allocation donut/cash blend/weights table, the correlation matrix, + * per-asset statistics, and saved runs (localStorage-backed — saving was + * left undesigned in the handoff; see the comment above the saved-runs + * functions below). + * + * No framework, no build step — plain DOM, matching the rest of the repo. + */ + +const UNIVERSE = [ + { t: "AAPL", n: "Apple" }, + { t: "MSFT", n: "Microsoft" }, + { t: "GOOGL", n: "Alphabet" }, + { t: "AMZN", n: "Amazon" }, + { t: "NVDA", n: "NVIDIA" }, + { t: "SPY", n: "S&P 500 ETF" }, + { t: "QQQ", n: "Nasdaq 100 ETF" }, + { t: "BND", n: "Total Bond ETF" }, + { t: "GLD", n: "Gold ETF" }, + { t: "VNQ", n: "Real Estate ETF" }, +]; + +const DEFAULT_SEL = [0, 1, 5, 7, 8]; // AAPL, MSFT, SPY, BND, GLD + +// Chart series order, assigned by position in the selection — shared by the +// frontier's per-asset dots and (in later PRs) the allocation donut/table. +const CHART_PALETTE = [ + "#2fc7cc", "#001d63", "#008ea0", "#42d4d7", "#64748b", + "#2c4d9c", "#00424c", "#94a3b8", "#5f7cbd", "#cbd5e1", +]; + +const PERIOD_WORDS = { + "1y": "1-year", + "2y": "2-year", + "5y": "5-year", + "10y": "10-year", + max: "all available", +}; + +// Pending vs. applied mirrors the design handoff's state shape: `sel` / +// `period` / `rfIdx` are what the rail currently shows; `appliedSel` / etc. +// are what `result` was actually solved from. They start out equal (we +// auto-run once on load), and diverge the moment the user touches a control +// — that's what drives "Run analysis" vs. "Re-run analysis". +const state = { + sel: [...DEFAULT_SEL], + period: "5y", + rfIdx: 8, + appliedSel: [...DEFAULT_SEL], + appliedPeriod: "5y", + appliedRfIdx: 8, + iF: null, // frontier selection; null = tangency (max Sharpe) + cash: 0, // cash blend; wired up in a later PR + units: null, // null | 'annual' + running: false, + result: null, + error: null, + solvedAt: null, +}; + +function isAnnual() { + return state.units === "annual"; +} +function toReturn(v) { + return isAnnual() ? Math.pow(1 + v, 12) - 1 : v; +} +function toVol(v) { + return isAnnual() ? v * Math.sqrt(12) : v; +} +function toSharpe(v) { + return isAnnual() ? v * Math.sqrt(12) : v; +} +function pct(v, decimals = 2) { + return (v * 100).toFixed(decimals) + "%"; +} +function rfOf(idx) { + return +(idx * 0.00025).toFixed(5); +} +function escapeHtml(s) { + return String(s).replace(/[&<>"']/g, (c) => ({ + "&": "&", "<": "<", ">": ">", '"': """, "'": "'", + })[c]); +} +function setText(id, text) { + const el = document.getElementById(id); + if (el) el.textContent = text; +} +/** "Nice" tick step for an axis running 0..max: the largest of 1/2/2.5/5 x + * 10^n that still gives at least 4 ticks. Same rule the design handoff's + * prototype uses, so frontier tick counts match the reference screenshots. */ +function niceTickValues(max) { + const raw = max / 4; + const mag = Math.pow(10, Math.floor(Math.log10(raw))); + const step = [1, 2, 2.5, 5, 10].map((m) => m * mag).find((s) => s >= raw) || mag * 10; + const out = []; + for (let v = 0; v <= max + 1e-9; v += step) out.push(v); + return out; +} + +function isStale() { + return ( + state.sel.join() !== state.appliedSel.join() || + state.period !== state.appliedPeriod || + state.rfIdx !== state.appliedRfIdx + ); +} + +/** Resolves `state.iF` against the current result: the effective frontier + * index, the tangency (max-Sharpe) index, and the descriptive label the + * design handoff uses for both the frontier stats panel and the KPI + * headline. `efficient_frontier[best]` and `max_sharpe_portfolio` are the + * same row from the backend's optimizer output, just serialized twice — + * reading through the frontier array here keeps a single source of truth + * for "which point is selected". */ +function resolveSelection(result) { + const pts = result.efficient_frontier; + const best = pts.reduce( + (b, p, i) => ((p.sharpe ?? -Infinity) > (pts[b].sharpe ?? -Infinity) ? i : b), + 0, + ); + const iF = state.iF == null ? best : Math.max(0, Math.min(pts.length - 1, state.iF)); + let label; + if (iF === best) label = "Tangency portfolio — maximum Sharpe"; + else if (iF === 0) label = "Minimum-variance portfolio"; + else if (iF < best) label = "Below tangency — risk-averse"; + else if (iF > pts.length - 5) label = "Frontier edge — single-asset concentration"; + else label = "Above tangency — return-seeking"; + return { iF, best, label, isBest: iF === best }; +} + +/** The portfolio the KPI band (and, later, the rest of the report) reads + * from. */ +function selectedPortfolio() { + if (!state.result) return null; + const { iF } = resolveSelection(state.result); + return state.result.efficient_frontier[iF]; +} + +/** Blends the selected portfolio with cash: a plain weighted average, valid + * for whatever point is currently selected — not the theoretical capital + * allocation line specifically, which (by construction) only dominates the + * frontier when it's anchored at the tangency portfolio. The backend's + * `capital_allocation_line` is fixed to the tangency portfolio for exactly + * that reason, so it can't be reused here once the frontier selection (PR 4) + * has moved off tangency; this local formula is what the design handoff's + * own prototype uses too, for any selected point, not just the tangency + * case the README's "prefer the API's CAL" note assumed. Sharpe is + * unaffected by a cash blend, so callers that need it just read `p.sharpe` + * directly. */ +function cashBlend(p) { + const cashP = state.cash / 100; + const rf = rfOf(state.appliedRfIdx); + return { + expectedReturn: cashP * rf + (1 - cashP) * p.expected_return, + risk: (1 - cashP) * p.risk, + }; +} + +async function runAnalysis() { + if (state.running) return; + state.running = true; + state.error = null; + render(); + + const tickers = state.sel.map((i) => UNIVERSE[i].t); + try { + const resp = await fetch("/api/analyze", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + tickers, + period: state.period, + risk_free_rate: rfOf(state.rfIdx), + }), + }); + const body = await resp.json().catch(() => ({})); + if (!resp.ok) { + throw new Error(body.detail || `Request failed (${resp.status})`); + } + + state.result = body; + state.appliedSel = [...state.sel]; + state.appliedPeriod = state.period; + state.appliedRfIdx = state.rfIdx; + state.iF = null; + state.cash = 0; + state.solvedAt = new Date().toTimeString().slice(0, 5); + } catch (err) { + state.error = err.message || String(err); + } finally { + state.running = false; + render(); + } +} + +/* ── Rendering ───────────────────────────────────────────────────────── */ + +function renderHeader() { + const universeTag = document.getElementById("mw-header-universe"); + const periodTag = document.getElementById("mw-header-period"); + const status = document.getElementById("mw-header-status"); + const unitsBtn = document.getElementById("mw-units-btn"); + + const shownSel = state.result ? state.appliedSel : state.sel; + const shownPeriod = state.result ? state.appliedPeriod : state.period; + universeTag.textContent = shownSel.map((i) => UNIVERSE[i].t).join(" · "); + periodTag.textContent = (shownPeriod === "max" ? "Max" : shownPeriod.toUpperCase()) + " monthly"; + status.textContent = state.result ? `Solved ${state.solvedAt}` : "Not run yet"; + unitsBtn.textContent = isAnnual() ? "Annualized" : "Monthly"; + + const runBtn = document.getElementById("mw-run-btn"); + runBtn.textContent = isStale() ? "Re-run analysis" : "Run analysis"; + runBtn.disabled = state.running; +} + +function renderChips() { + document.getElementById("mw-chips").querySelectorAll(".mw-chip").forEach((btn) => { + const idx = +btn.dataset.idx; + btn.classList.toggle("mw-chip--selected", state.sel.includes(idx)); + }); + document.getElementById("mw-chip-count").textContent = + `${state.sel.length} of ${UNIVERSE.length} selected · minimum 2`; +} + +function renderOverlay() { + const overlay = document.getElementById("mw-overlay"); + overlay.hidden = !state.running; + document.getElementById("mw-overlay-note").textContent = + state.sel.map((i) => UNIVERSE[i].t).join(" · "); +} + +function kpiSectionHtml() { + const p = selectedPortfolio(); + const unitWord = isAnnual() ? "annualized" : "monthly"; + const n = state.appliedSel.length; + + const { label, isBest } = resolveSelection(state.result); + const headline = isBest ? `The best risk-adjusted mix of your ${n} asset${n === 1 ? "" : "s"}` : label; + const periodWords = PERIOD_WORDS[state.appliedPeriod] || state.appliedPeriod; + const lede = + `Estimated from ${periodWords} monthly history at a ${pct(toReturn(rfOf(state.appliedRfIdx)))} ` + + `${unitWord} risk-free rate. ` + + (state.cash > 0 + ? `Figures below include a ${state.cash}% cash position.` + : "Fully invested in the risky portfolio."); + + const weights = Object.values(p.weights); + const holdings = weights.filter((w) => w > 0.005).length; + const diversification = 1 / weights.reduce((a, w) => a + w * w, 0); + const blend = cashBlend(p); + + const kpis = [ + { label: "Expected return", value: pct(toReturn(blend.expectedReturn)), note: unitWord }, + { label: "Volatility", value: pct(toVol(blend.risk)), note: "standard deviation" }, + { label: "Sharpe ratio", value: toSharpe(p.sharpe).toFixed(2), note: "unchanged by the cash blend", accent: true }, + { label: "Holdings", value: String(holdings), note: `of ${n} assets, non-zero weight` }, + { label: "Diversification", value: diversification.toFixed(1), note: "effective assets held" }, + ]; + + return ` +
+
Portfolio report
+

${escapeHtml(headline)}

+

${escapeHtml(lede)}

+
+ ${kpis.map((k) => ` +
+ ${escapeHtml(k.label)} + ${escapeHtml(k.value)} + ${escapeHtml(k.note)} +
`).join("")} +
+
`; +} + +function errorCardHtml() { + return ` +
+ Analysis failed +

${escapeHtml(state.error)}

+ +
`; +} + +/* ── Efficient frontier ──────────────────────────────────────────────── */ + +// SVG plot box, per the handoff: viewBox 0 0 880 470, x from 62 to 862 +// (zero at 62), y from 404 (zero) up to 26. +const FR_X0 = 62; +const FR_X_SPAN = 800; +const FR_Y0 = 404; +const FR_Y_SPAN = 378; + +// Rebuilt only when `state.result` changes (a new run) — holds the scale +// functions and point coordinates the base SVG and the selection overlay +// both read. Recomputing this on every drag frame would be wasteful and, +// worse, would mean tearing down the SVG element mid-drag and losing its +// pointer capture (see renderFrontier below). +let frontierGeo = null; +let frontierBaseResult = null; +let frontierBaseUnits = null; + +function computeFrontierGeometry(result) { + const pts = result.efficient_frontier; + const assetStats = result.asset_statistics || []; + const xMax = Math.max(...pts.map((q) => q.risk), ...assetStats.map((a) => a.volatility)) * 1.08; + const yMax = + Math.max(...pts.map((q) => q.expected_return), ...assetStats.map((a) => a.expected_return)) * 1.12; + const X = (v) => FR_X0 + (v / xMax) * FR_X_SPAN; + const Y = (v) => FR_Y0 - (v / yMax) * FR_Y_SPAN; + const geo = pts.map((q) => ({ cx: +X(q.risk).toFixed(1), cy: +Y(q.expected_return).toFixed(1) })); + return { pts, assetStats, xMax, yMax, X, Y, geo }; +} + +/** Maps a pointer event's x position to the nearest frontier point by risk, + * accounting for the plot box's left inset — not a naive fraction of the + * SVG element's width. */ +function pickFrontierPoint(evt, svgEl) { + const rect = svgEl.getBoundingClientRect(); + const t = Math.max( + 0, + Math.min(1, (evt.clientX - rect.left - (FR_X0 / 880) * rect.width) / ((FR_X_SPAN / 880) * rect.width)), + ); + const target = t * frontierGeo.xMax; + let bestIdx = 0; + let bestDist = Infinity; + frontierGeo.pts.forEach((q, i) => { + const d = Math.abs(q.risk - target); + if (d < bestDist) { + bestDist = d; + bestIdx = i; + } + }); + state.iF = bestIdx; + render(); +} + +function frontierSectionHtml(result) { + const geo = frontierGeo; + const rf = rfOf(state.appliedRfIdx); + const { best } = resolveSelection(result); + + const gridPath = niceTickValues(geo.yMax).map((v) => `M${FR_X0} ${geo.Y(v).toFixed(1)}H862`).join(" "); + const xTickPath = niceTickValues(geo.xMax).map((v) => `M${geo.X(v).toFixed(1)} ${FR_Y0}v7`).join(" "); + const dotsPath = geo.geo + .map((g) => `M${(g.cx - 2.2).toFixed(1)} ${g.cy}a2.2 2.2 0 1 0 4.4 0a2.2 2.2 0 1 0 -4.4 0`) + .join(" "); + const line = geo.geo.map((g) => `${g.cx},${g.cy}`).join(" "); + const mvp = geo.geo[0]; + const tan = geo.geo[best]; + + const tanPoint = geo.pts[best]; + const slope = (tanPoint.expected_return - rf) / tanPoint.risk; + let calX = geo.xMax; + let calY = rf + slope * calX; + if (calY > geo.yMax) { + calY = geo.yMax; + calX = (geo.yMax - rf) / slope; + } + const cal = { + x1: geo.X(0).toFixed(1), y1: geo.Y(rf).toFixed(1), + x2: geo.X(calX).toFixed(1), y2: geo.Y(calY).toFixed(1), + pctX: (((geo.X(calX) - 6) / 880) * 100).toFixed(2), + pctY: (((geo.Y(calY) + 6) / 470) * 100).toFixed(2), + }; + + const unitWord = isAnnual() ? "annualized" : "monthly"; + const yTicks = niceTickValues(geo.yMax).map((v) => ({ + pctY: ((geo.Y(v) / 470) * 100).toFixed(2), + label: pct(toReturn(v), isAnnual() ? 0 : 1), + })); + const xTicks = niceTickValues(geo.xMax).map((v) => ({ + pctX: ((geo.X(v) / 880) * 100).toFixed(2), + label: pct(toVol(v), isAnnual() ? 0 : 1), + })); + + const statsByTicker = Object.fromEntries(geo.assetStats.map((a) => [a.ticker, a])); + const assetDots = result.tickers + .map((t, k) => { + const a = statsByTicker[t]; + if (!a) return null; // shouldn't happen — every requested ticker gets stats back + return { + t, + color: CHART_PALETTE[k % CHART_PALETTE.length], + pctX: ((geo.X(a.volatility) / 880) * 100).toFixed(2), + pctY: ((geo.Y(a.expected_return) / 470) * 100).toFixed(2), + }; + }) + .filter(Boolean); + + return ` +
+
01 · Risk–return
+

Efficient frontier

+

Every point is a portfolio the optimizer can build from your + ${result.tickers.length} assets. Click or drag along the curve to move the selection — the whole + report above follows it.

+ +
+
+
+ + + + + + + + + + + + + + +
+ ${yTicks.map((t) => `${t.label}`).join("")} + ${xTicks.map((t) => `${t.label}`).join("")} + ${assetDots.map((a) => ` + ${escapeHtml(a.t)} + `).join("")} + Volatility σ → + ↑ Expected return · ${unitWord} + Capital allocation line +
+
+
+ Frontier + Tangency + Minimum variance + Single asset +
+
+ +
+
+ Selected portfolio +
+
+
+
Expected return
+
Volatility
+
Sharpe ratio
+
Largest position
+
+

+
+ + +
+
+
+
`; +} + +function attachFrontierEvents() { + const svg = document.getElementById("mw-frontier-svg"); + if (!svg) return; + svg.addEventListener("pointerdown", (e) => { + try { + if (e.pointerId != null) svg.setPointerCapture(e.pointerId); + } catch { + // Synthetic events (and some test harnesses) may lack a real pointerId. + } + svg._mwDragging = true; + pickFrontierPoint(e, svg); + }); + svg.addEventListener("pointermove", (e) => { + if (svg._mwDragging) pickFrontierPoint(e, svg); + }); + svg.addEventListener("pointerup", () => { + svg._mwDragging = false; + }); + svg.addEventListener("pointercancel", () => { + svg._mwDragging = false; + }); +} + +/** Updates only the selection-dependent bits (crosshair, marker, stats + * panel) without touching the SVG element itself — rebuilding it mid-drag + * would drop the pointer capture attachFrontierEvents just set up. */ +function updateFrontierSelection() { + if (!state.result || !frontierGeo) return; + const { iF, best, label, isBest } = resolveSelection(state.result); + const p = frontierGeo.pts[iF]; + const g = frontierGeo.geo[iF]; + + const crossH = document.getElementById("mw-fr-cross-h"); + const crossV = document.getElementById("mw-fr-cross-v"); + const halo = document.getElementById("mw-fr-sel-halo"); + const dot = document.getElementById("mw-fr-sel-dot"); + if (crossH) { + crossH.setAttribute("x2", g.cx); + crossH.setAttribute("y1", g.cy); + crossH.setAttribute("y2", g.cy); + } + if (crossV) { + crossV.setAttribute("x1", g.cx); + crossV.setAttribute("x2", g.cx); + crossV.setAttribute("y1", g.cy); + } + if (halo) { + halo.setAttribute("cx", g.cx); + halo.setAttribute("cy", g.cy); + } + if (dot) { + dot.setAttribute("cx", g.cx); + dot.setAttribute("cy", g.cy); + } + + setText("mw-fr-title", label); + setText("mw-fr-row-return", pct(toReturn(p.expected_return))); + setText("mw-fr-row-risk", pct(toVol(p.risk))); + setText("mw-fr-row-sharpe", toSharpe(p.sharpe).toFixed(3)); + + const [largestTicker, largestWeight] = Object.entries(p.weights).reduce( + (a, [t, w]) => (w > a[1] ? [t, w] : a), + ["—", -Infinity], + ); + setText("mw-fr-row-largest", `${largestTicker} · ${(largestWeight * 100).toFixed(0)}%`); + + setText( + "mw-fr-tip", + isBest + ? "This is where the capital allocation line touches the frontier: no other long-only mix of these assets pays more return per unit of risk." + : iF < best + ? "Safer than the tangency portfolio, but every unit of risk here buys less return. Blending the tangency mix with cash dominates this point." + : "Past tangency the curve flattens: additional return costs more volatility than it returns. Low-volatility assets have dropped out.", + ); +} + +function renderFrontier() { + const slot = document.getElementById("mw-frontier-slot"); + // A failed re-run leaves the previous `state.result` in place (see + // runAnalysis) so a transient failure doesn't blow away a working report; + // but showing a stale chart under the error card would be confusing, so + // the error card (in the KPI slot) takes over the whole report area. + if (!state.result || state.error) { + slot.innerHTML = ""; + frontierGeo = null; + frontierBaseResult = null; + frontierBaseUnits = null; + return; + } + // Units (monthly/annual) change the axis titles and tick labels, which are + // baked into the static HTML below — not just the selection-dependent + // bits updateFrontierSelection touches — so a units change needs a base + // rebuild too. That's safe here (unlike mid-drag) because toggling units + // is a discrete click, never a pointermove while the SVG holds capture. + if (state.result !== frontierBaseResult || state.units !== frontierBaseUnits) { + frontierGeo = computeFrontierGeometry(state.result); + slot.innerHTML = frontierSectionHtml(state.result); + attachFrontierEvents(); + frontierBaseResult = state.result; + frontierBaseUnits = state.units; + } + updateFrontierSelection(); +} + +/* ── Optimal allocation ──────────────────────────────────────────────── */ + +const DONUT_CIRCUMFERENCE = 2 * Math.PI * 52; + +function nameFor(ticker) { + return UNIVERSE.find((u) => u.t === ticker)?.n || ticker; +} + +// Only the row identity (tickers/names/colors, from `result.tickers`) is +// "base" — it never changes for a given result, regardless of frontier +// selection, cash or units. Everything else (weights, bar widths, the donut, +// the blended figures) is recomputed on every render into allocationUpdate(). +let allocationBaseResult = null; + +function allocationRowsHtml(result) { + const p = selectedPortfolio(); + const cashP = state.cash / 100; + + const rows = result.tickers.map((t, k) => { + const w = p.weights[t] ?? 0; + return { + ticker: t, + name: nameFor(t), + color: CHART_PALETTE[k % CHART_PALETTE.length], + w, + capPct: w * (1 - cashP) * 100, + }; + }); + + const cols = "104px minmax(0,1fr) 104px minmax(90px,22%) 104px"; + const assetRows = rows.map((r) => ` +
+ ${escapeHtml(r.ticker)} + ${escapeHtml(r.name)} + ${pct(r.w, 1)} + + ${pct(r.capPct / 100, 1)} +
`).join(""); + + const cashRow = ` +
+ CASH + Risk-free + — + + ${state.cash}% +
`; + + return assetRows + cashRow; +} + +function allocationArcsHtml(result) { + const p = selectedPortfolio(); + const cashP = state.cash / 100; + let acc = 0; + return result.tickers + .map((t, k) => ({ w: p.weights[t] ?? 0, color: CHART_PALETTE[k % CHART_PALETTE.length] })) + .filter((r) => r.w > 0.0005) + .map((r) => { + const len = r.w * (1 - cashP) * DONUT_CIRCUMFERENCE; + const dash = `${len.toFixed(1)} ${(DONUT_CIRCUMFERENCE - len).toFixed(1)}`; + const offset = (-acc).toFixed(1); + acc += len; + return ``; + }) + .join(""); +} + +function allocationBaseHtml(result) { + return ` +
+
02 · Holdings
+

Optimal allocation

+

Weights for the selected portfolio. Blending with cash walks down the + capital allocation line: return and risk both scale, the Sharpe ratio does not move.

+ +
+
+
+ + + + +
+
+
+
+ +
+
+
+ Blend with cash + +
+ +
+
+ +
+
+
+ AssetNameIn risky mixShare of capitalOf capital +
+
+
+
+
+
+
`; +} + +function attachAllocationEvents() { + const cash = document.getElementById("mw-cash"); + if (!cash) return; + cash.addEventListener("input", () => { + state.cash = +cash.value; + render(); + }); +} + +/** Refreshes everything that depends on the selected portfolio, cash or + * units — the donut arcs, its center overlay, the cash caption, and the + * weights rows. Deliberately does not touch `#mw-cash` itself: this runs on + * every `input` event the slider fires, and replacing the slider element + * mid-drag would interrupt the browser's own drag gesture on it (the same + * class of bug the frontier chart's pointer capture has to avoid). */ +function updateAllocation() { + const p = selectedPortfolio(); + const unitWord = isAnnual() ? "annualized" : "monthly"; + const blend = cashBlend(p); + + document.getElementById("mw-alloc-arcs").innerHTML = allocationArcsHtml(state.result); + document.getElementById("mw-alloc-overlay").innerHTML = ` + ${escapeHtml(pct(toReturn(blend.expectedReturn)))} + expected · ${escapeHtml(unitWord)}`; + setText("mw-alloc-note", state.cash > 0 ? `Risky mix at ${100 - state.cash}% of capital` : "Fully invested"); + setText("mw-cash-pct", `${state.cash}%`); + setText( + "mw-cal-note", + `At ${state.cash}% cash: ${pct(toReturn(blend.expectedReturn))} expected return, ` + + `${pct(toVol(blend.risk))} volatility, Sharpe ${toSharpe(p.sharpe).toFixed(2)}.`, + ); + document.getElementById("mw-alloc-rows").innerHTML = allocationRowsHtml(state.result); +} + +function renderAllocation() { + const slot = document.getElementById("mw-allocation-slot"); + if (!state.result || state.error) { + slot.innerHTML = ""; + allocationBaseResult = null; + return; + } + if (state.result !== allocationBaseResult) { + slot.innerHTML = allocationBaseHtml(state.result); + attachAllocationEvents(); + allocationBaseResult = state.result; + } + updateAllocation(); +} + +/* ── Correlation matrix ──────────────────────────────────────────────── */ + +// Linear RGB interpolation from neutral-900 (0.0) to accent-600 (1.0). Text +// stays ink-dark at every value — the ramp tops out light enough that a +// contrast threshold (needed in the mobile design this superseded) isn't +// needed here. +const CORR_LOW_RGB = [241, 245, 249]; +const CORR_HIGH_RGB = [94, 213, 217]; + +function correlationCellColor(v) { + const t = Math.max(0, Math.min(1, v)); + const rgb = CORR_LOW_RGB.map((u, k) => Math.round(u + (CORR_HIGH_RGB[k] - u) * t)); + return `rgb(${rgb.join(",")})`; +} + +function correlationSectionHtml(result) { + const tickers = result.tickers; + const corr = result.correlation_matrix; + + const heads = tickers + .map((t) => `${escapeHtml(t)}`) + .join(""); + + const rows = tickers + .map((rowTicker, i) => { + const label = `${escapeHtml(rowTicker)}`; + const cells = tickers + .map((_, j) => { + const v = corr[i][j]; + return `${v.toFixed(2)}`; + }) + .join(""); + return label + cells; + }) + .join(""); + + // Least/most correlated pair, scanning the upper triangle only (each pair once). + let lo = { v: 2, a: 0, b: 0 }; + let hi = { v: -2, a: 0, b: 0 }; + for (let i = 0; i < tickers.length; i++) { + for (let j = i + 1; j < tickers.length; j++) { + const v = corr[i][j]; + if (v < lo.v) lo = { v, a: i, b: j }; + if (v > hi.v) hi = { v, a: i, b: j }; + } + } + + const matrixMin = 58 + tickers.length * 41; + const periodWords = state.appliedPeriod === "max" ? "all available history" : state.appliedPeriod.replace("y", " years"); + + return ` +
+
03 · Diversification
+

Correlation matrix

+

Pairwise correlation of monthly returns over ${escapeHtml(periodWords)}. + Low pairs are what let the optimizer cut risk without giving up return.

+ +
+
+
+ + ${heads} + ${rows} +
+
+ 0.0 + + 1.0 +
+
+ +
+ Least correlated pair +
${escapeHtml(tickers[lo.a])} · ${escapeHtml(tickers[lo.b])}
+
${lo.v.toFixed(2)}
+

Two assets that rarely move together reduce portfolio variance without reducing + expected return, which is why the optimizer holds both even when one has the weaker standalone record.

+ +
+
+
`; +} + +function renderCorrelation() { + const slot = document.getElementById("mw-correlation-slot"); + slot.innerHTML = state.result && !state.error ? correlationSectionHtml(state.result) : ""; +} + +/* ── Per-asset statistics ────────────────────────────────────────────── */ + +function assetStatsSectionHtml(result) { + const p = selectedPortfolio(); + const rf = rfOf(state.appliedRfIdx); + const statsByTicker = Object.fromEntries((result.asset_statistics || []).map((a) => [a.ticker, a])); + const cols = "104px minmax(0,1fr) 124px 104px 136px 96px"; + + const rows = result.tickers + .map((t, k) => { + const a = statsByTicker[t]; + const w = p.weights[t] ?? 0; + const standaloneSharpe = (a.expected_return - rf) / a.volatility; + const wColor = w > 0.005 ? "var(--color-text)" : "var(--color-neutral-600)"; + return ` +
+ ${escapeHtml(t)} + ${escapeHtml(nameFor(t))} + ${pct(toReturn(a.expected_return))} + ${pct(toVol(a.volatility))} + ${toSharpe(standaloneSharpe).toFixed(2)} + ${pct(w, 1)} +
`; + }) + .join(""); + + return ` +
+
04 · Inputs
+

Per-asset statistics

+

What the optimizer was given. A high standalone Sharpe does not guarantee + a large weight — covariance decides.

+
+
+
+ AssetNameExpected returnVolatilitySharpe, standaloneWeight +
+ ${rows} +
+
+
`; +} + +function renderAssetStats() { + const slot = document.getElementById("mw-assets-slot"); + slot.innerHTML = state.result && !state.error ? assetStatsSectionHtml(state.result) : ""; +} + +/* ── Saved runs ────────────────────────────────────────────────────────── + * Not designed in the handoff — section 05 is fixtures only there ("saving + * is not implemented... see Gaps"). Everything below (the save action, the + * storage format, load/delete) is this project's own invention, built to + * the agreed v1 shape: kept in this browser's localStorage, no server + * changes. */ + +const SAVED_RUNS_KEY = "markowizard.savedRuns"; + +function loadSavedRuns() { + try { + const raw = localStorage.getItem(SAVED_RUNS_KEY); + const parsed = raw ? JSON.parse(raw) : []; + return Array.isArray(parsed) ? parsed : []; + } catch { + // Storage unavailable (private browsing, disabled, corrupted value) — + // degrade to "no saved runs" rather than breaking the report. + return []; + } +} + +function writeSavedRuns(runs) { + try { + localStorage.setItem(SAVED_RUNS_KEY, JSON.stringify(runs)); + } catch { + // Save silently doesn't persist (e.g. quota exceeded) — not worth a + // user-facing error for a convenience feature with no design spec. + } +} + +function saveCurrentRun() { + if (!state.result || state.error) return; + const p = selectedPortfolio(); + const periodLabel = state.appliedPeriod === "max" ? "Max" : state.appliedPeriod.toUpperCase(); + const defaultName = `${state.appliedSel.length} assets · ${periodLabel}`; + const name = window.prompt("Name this saved run:", defaultName); + if (name === null) return; // cancelled + + const runs = loadSavedRuns(); + runs.unshift({ + id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + name: name.trim() || defaultName, + date: new Date().toLocaleString(undefined, { + month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", + }), + sel: [...state.appliedSel], + period: state.appliedPeriod, + rfIdx: state.appliedRfIdx, + // Raw (pre-cash) selected-portfolio figures — cash is transient UI + // state, not part of the saved artifact, and resets to 0 on load just + // like it does on a fresh run. + expectedReturn: p.expected_return, + risk: p.risk, + sharpe: p.sharpe, + }); + writeSavedRuns(runs); + render(); +} + +function loadSavedRun(id) { + const run = loadSavedRuns().find((r) => r.id === id); + if (!run) return; + state.sel = [...run.sel]; + state.period = run.period; + state.rfIdx = run.rfIdx; + runAnalysis(); +} + +function deleteSavedRun(id) { + writeSavedRuns(loadSavedRuns().filter((r) => r.id !== id)); + render(); +} + +function savedRunsSectionHtml() { + const runs = loadSavedRuns(); + const canSave = !!state.result && !state.error; + + const header = ` +
+
+
05 · History
+

Saved runs

+

Kept locally in this browser. Load one to replace + the report above.

+
+ +
`; + + if (runs.length === 0) { + return ` +
+ ${header} +
+ +

No saved runs yet — run an analysis, then save it to come back to it later.

+
+
`; + } + + const cols = "minmax(0,1.1fr) minmax(0,1.6fr) 112px 92px 92px 80px 76px"; + const rows = runs + .map((r) => { + const universe = r.sel.map((i) => UNIVERSE[i]?.t).filter(Boolean).join(" · "); + const windowLabel = (r.period === "max" ? "Max" : r.period.toUpperCase()) + " monthly"; + return ` +
+ + ${escapeHtml(r.name)} + ${escapeHtml(r.date)} + + ${escapeHtml(universe)} + ${escapeHtml(windowLabel)} + ${pct(toReturn(r.expectedReturn))} + ${pct(toVol(r.risk))} + ${toSharpe(r.sharpe).toFixed(2)} + + + + +
`; + }) + .join(""); + + return ` +
+ ${header} +
+
+
+ RunUniverseWindowReturnRiskSharpe +
+ ${rows} +
+
+
`; +} + +// Unlike the other report sections, saved runs isn't gated on state.result: +// it's a persistent list independent of whether the current run succeeded +// (and it stays usable — Load included — even after a failed re-run). +function renderSavedRuns() { + document.getElementById("mw-saved-slot").innerHTML = savedRunsSectionHtml(); +} + +function renderKpiSlot() { + const slot = document.getElementById("mw-kpi-slot"); + if (state.error) { + slot.innerHTML = errorCardHtml(); + } else if (state.result) { + slot.innerHTML = kpiSectionHtml(); + } else { + slot.innerHTML = ""; + } +} + +function render() { + renderHeader(); + renderChips(); + renderOverlay(); + renderKpiSlot(); + renderFrontier(); + renderAllocation(); + renderCorrelation(); + renderAssetStats(); + renderSavedRuns(); +} + +/* ── Event wiring ────────────────────────────────────────────────────── */ + +function init() { + document.getElementById("mw-chips").addEventListener("click", (e) => { + const btn = e.target.closest(".mw-chip"); + if (!btn) return; + const idx = +btn.dataset.idx; + const selected = state.sel.includes(idx); + if (selected && state.sel.length <= 2) return; // minimum 2, per the handoff + state.sel = selected ? state.sel.filter((i) => i !== idx) : [...state.sel, idx].sort((a, b) => a - b); + render(); + }); + + document.getElementById("mw-period").querySelectorAll('input[name="mwperiod"]').forEach((input) => { + input.addEventListener("change", () => { + state.period = input.value; + render(); + }); + }); + + const rf = document.getElementById("mwrf"); + rf.addEventListener("input", () => { + state.rfIdx = +rf.value; + document.getElementById("mwrf-display").textContent = pct(toReturn(rfOf(state.rfIdx))); + render(); + }); + + document.getElementById("mw-units-btn").addEventListener("click", () => { + state.units = isAnnual() ? null : "annual"; + render(); + }); + + // Delegated: the rail's Run button, the error card's Retry button, and the + // frontier stats panel's jump buttons are all rebuilt on every re-render, + // so none of them can have a listener attached directly. + document.body.addEventListener("click", (e) => { + if (e.target.closest('[data-action="run"]')) runAnalysis(); + if (e.target.closest('[data-action="frontier-mvp"]')) { + state.iF = 0; + render(); + } + if (e.target.closest('[data-action="frontier-tan"]')) { + state.iF = null; + render(); + } + if (e.target.closest('[data-action="save-run"]')) saveCurrentRun(); + const loadBtn = e.target.closest('[data-action="load-run"]'); + if (loadBtn) loadSavedRun(loadBtn.dataset.runId); + const deleteBtn = e.target.closest('[data-action="delete-run"]'); + if (deleteBtn) deleteSavedRun(deleteBtn.dataset.runId); + }); + + render(); + runAnalysis(); +} + +document.addEventListener("DOMContentLoaded", init); diff --git a/frontend/index.html b/frontend/index.html index 86bac8f..4bc3b9a 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -18,10 +18,10 @@ MarkoWizard - AAPL · MSFT · SPY · BND · GLD - 5Y monthly - Not run yet - + AAPL · MSFT · SPY · BND · GLD + 5Y monthly + Not run yet +
@@ -29,24 +29,24 @@
Universe
-
- - - - - - - - - - +
+ + + + + + + + + +
-
5 of 10 selected · minimum 2
+
5 of 10 selected · minimum 2
History window
-
+
@@ -58,12 +58,12 @@
History window
- 0.20% + 0.20%
- +
+ + + + diff --git a/frontend/style.css b/frontend/style.css index da7384f..11c839f 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -79,6 +79,14 @@ --radius-sm: 4px; --radius-md: 8px; --radius-lg: 14px; + + /* Semantic (outliers-design-system tokens/colors.json) — only --color-error + is used so far (analysis-failed states); the rest are here so later PRs + don't have to re-derive them. */ + --color-success: #22c55e; + --color-warning: #f59e0b; + --color-error: #ef4444; + --color-info: #3b82f6; } /* ── Reset & typography ─────────────────────────────────────────────── */ @@ -516,3 +524,401 @@ input[type="range"]:hover::-moz-range-thumb { gap: var(--space-2); padding: var(--space-8); } + +.mw-chip-row { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); +} + +/* ── KPI band ────────────────────────────────────────────────────────── */ + +.mw-headline { + font-size: clamp(30px, 3.4vw, 40px); + margin: 0 0 var(--space-3); +} +.mw-lede { + max-width: 64ch; + font-size: 15px; + margin-bottom: var(--space-8); +} +.mw-kpi-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(168px, 1fr)); + gap: var(--space-4); +} +.mw-kpi-value { + font-family: var(--font-heading); + font-weight: 500; + font-size: clamp(24px, 2.2vw, 31px); + line-height: 1.1; + letter-spacing: -0.02em; + color: var(--color-text); +} +.mw-kpi-value--accent { + color: var(--color-accent-300); +} + +/* ── Error state ─────────────────────────────────────────────────────── */ + +.mw-error { + border: 1px solid color-mix(in srgb, var(--color-error) 35%, transparent); +} +.mw-error .card-kicker { + color: var(--color-error); +} + +/* ── Running overlay ─────────────────────────────────────────────────── */ + +.mw-overlay { + position: fixed; + inset: 0; + z-index: 40; + display: grid; + place-items: center; + background: color-mix(in srgb, var(--color-bg) 82%, transparent); +} +/* `.mw-overlay`'s own `display` would otherwise outrank the UA stylesheet's + `[hidden]{display:none}` at equal specificity (source order) — pin it + explicitly so setting the `hidden` property from JS always wins. */ +.mw-overlay[hidden] { + display: none; +} +.mw-overlay__card { + align-items: center; + gap: var(--space-4); + padding: var(--space-8); + width: min(300px, 86vw); +} +.mw-overlay__card img { + width: 84px; + height: 84px; + object-fit: contain; +} +.mw-overlay__title { + font-family: var(--font-heading); + font-size: 14px; + text-align: center; +} +.mw-overlay__note { + font-size: 11.5px; + text-align: center; +} +.mw-spinner { + width: 30px; + height: 30px; + border-radius: 50%; + border: 3px solid var(--color-neutral-800); + border-top-color: var(--color-accent-400); + animation: mwspin 0.8s linear infinite; +} + +/* ── Section chrome shared by frontier/allocation/correlation/etc ──────── */ + +.mw-section-lede { + max-width: 70ch; + font-size: 14px; + margin-bottom: var(--space-6); +} + +/* ── Efficient frontier ──────────────────────────────────────────────── */ + +.mw-frontier-row { + display: flex; + flex-wrap: wrap; + gap: var(--space-4); + align-items: stretch; +} +.mw-frontier-chart-card { + flex: 999 1 560px; + min-width: 0; + padding: var(--space-4); +} +.mw-frontier-plot { + position: relative; + width: 100%; +} +.mw-frontier-svg { + width: 100%; + height: auto; + display: block; + touch-action: none; + cursor: crosshair; +} +.mw-frontier-overlay { + position: absolute; + inset: 0; + pointer-events: none; + font-size: 12.5px; + color: var(--color-neutral-400); + font-variant-numeric: tabular-nums; +} +.mw-frontier-overlay span { + position: absolute; + white-space: nowrap; +} +.mw-frontier-legend { + display: flex; + flex-wrap: wrap; + gap: var(--space-4); + padding-top: var(--space-2); +} +.mw-frontier-legend .mw-legend-item { + font-size: 11.5px; + display: flex; + align-items: center; + gap: 6px; +} +.mw-frontier-stats { + flex: 1 1 276px; + min-width: 0; + gap: var(--space-4); + padding: var(--space-4); +} +.mw-frontier-stats__title { + font-family: var(--font-heading); + font-size: 18px; + line-height: 1.25; + margin-top: var(--space-1); +} +.mw-frontier-stats__rows { + display: flex; + flex-direction: column; + gap: var(--space-3); +} +.mw-frontier-stats__row { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var(--space-3); + background: linear-gradient(to right, var(--color-divider), var(--color-divider)) no-repeat bottom / + 100% 1px; + padding-bottom: var(--space-2); +} +.mw-frontier-stats__row-value { + font-family: var(--font-heading); + font-size: 16px; +} + +/* ── Shared "not a " grid rows ──────────────────────────────────── + Reused by the allocation weights table and (in later PRs) the + correlation, per-asset-statistics and saved-runs sections — a header row + plus one CSS-grid row per record, each column count/width supplied by the + caller via an inline grid-template-columns (they differ per table). */ + +.mw-table { + padding: var(--space-4) 0; + overflow-x: auto; +} +.mw-table__inner { + display: flex; + flex-direction: column; +} +.mw-grid-row { + display: grid; + align-items: center; + gap: var(--space-3); + padding: var(--space-3) var(--space-6); + font-size: 14px; +} +.mw-grid-row--head { + padding: 0 var(--space-6) var(--space-2); + font-size: 11px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--color-neutral-500); + background: + linear-gradient( + to right, + transparent, + var(--color-divider) 48px, + var(--color-divider) calc(100% - 48px), + transparent + ) + no-repeat bottom / 100% 1px; +} +.mw-grid-row--body { + background: + linear-gradient( + to right, + transparent, + color-mix(in srgb, var(--color-text) 8%, transparent) 48px, + color-mix(in srgb, var(--color-text) 8%, transparent) calc(100% - 48px), + transparent + ) + no-repeat bottom / 100% 1px; +} +.mw-swatch { + display: inline-flex; + align-items: center; + gap: 8px; + font-family: var(--font-heading); +} +.mw-swatch__dot { + width: 8px; + height: 8px; + border-radius: 2px; + display: block; + flex: none; +} +.mw-bar-track { + display: block; + height: 6px; + border-radius: 3px; + background: var(--color-neutral-800); + overflow: hidden; +} +.mw-bar-fill { + display: block; + height: 100%; + border-radius: 3px; +} +.mw-num { + font-variant-numeric: tabular-nums; +} + +/* ── Optimal allocation ──────────────────────────────────────────────── */ + +.mw-alloc-row { + display: flex; + flex-wrap: wrap; + gap: var(--space-4); + align-items: flex-start; +} +.mw-alloc-donut-card { + flex: 1 1 232px; + align-items: center; + padding: var(--space-6); +} +.mw-alloc-donut { + position: relative; + width: 100%; + max-width: 210px; + aspect-ratio: 1 / 1; +} +.mw-alloc-donut svg { + width: 100%; + height: 100%; + display: block; +} +.mw-alloc-donut__overlay { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 2px; + pointer-events: none; + text-align: center; +} +.mw-alloc-donut__value { + font-family: var(--font-heading); + font-size: clamp(20px, 2vw, 26px); + letter-spacing: -0.02em; +} +.mw-alloc-donut__note { + font-size: 11.5px; + text-align: center; +} +.mw-alloc-main { + flex: 999 1 460px; + min-width: 0; + display: flex; + flex-direction: column; + gap: var(--space-4); +} +.mw-cash-card { + padding: var(--space-4); +} +.mw-cash-card__head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var(--space-4); + flex-wrap: wrap; +} +.mw-cash-card__pct { + font-family: var(--font-heading); + font-size: 15px; + color: var(--color-accent-300); +} +.mw-cash-card input[type="range"] { + width: 100%; +} + +/* ── Correlation matrix ──────────────────────────────────────────────── */ + +.mw-corr-row { + display: flex; + flex-wrap: wrap; + gap: var(--space-4); + align-items: flex-start; +} +.mw-corr-matrix-card { + flex: 0 1 auto; + min-width: 0; + padding: var(--space-6); + overflow-x: auto; +} +.mw-corr-grid { + display: grid; + justify-content: start; + gap: 3px; +} +.mw-corr-legend { + display: flex; + align-items: center; + gap: var(--space-3); + margin-top: var(--space-4); + max-width: 320px; +} +.mw-corr-legend__bar { + flex: 1; + height: 6px; + border-radius: 3px; + background: linear-gradient(90deg, var(--color-neutral-900), var(--color-accent-600)); + display: block; +} +.mw-corr-callout { + flex: 1 1 300px; + max-width: 400px; + min-width: 0; + padding: var(--space-6); + gap: var(--space-3); +} +.mw-corr-callout__pair { + font-family: var(--font-heading); + font-size: 20px; + line-height: 1.2; +} +.mw-corr-callout__value { + font-family: var(--font-heading); + font-size: 31px; + color: var(--color-accent-300); + letter-spacing: -0.02em; +} +.mw-corr-callout__footer { + display: flex; + align-items: baseline; + justify-content: space-between; + padding-top: var(--space-2); + background: linear-gradient(to right, var(--color-divider), var(--color-divider)) no-repeat top / 100% 1px; +} + +/* ── Saved runs ──────────────────────────────────────────────────────── */ + +.mw-saved-head { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: var(--space-4); + flex-wrap: wrap; + margin-bottom: var(--space-6); +} + +.mw-disclaimer { + font-size: 11.5px; + max-width: 76ch; + margin: 0; +}