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)}
+
Retry
+
`;
+}
+
+/* ── 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
+
+
+
+
+
+
+
Expected return
+
Volatility
+
Sharpe ratio
+
Largest position
+
+
+
+ Min variance
+ Max Sharpe
+
+
+
+ `;
+}
+
+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
+
+
+
+
+
+
+
+
+
+ Asset Name In risky mix Share of capital Of 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.
+
+
+
+ Asset Name Expected return Volatility Sharpe, standalone Weight
+
+ ${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.
+
+
Save this run
+
`;
+
+ 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)}
+
+ Load
+ ✕
+
+
`;
+ })
+ .join("");
+
+ return `
+
+ ${header}
+
+
+
+ Run Universe Window Return Risk Sharpe
+
+ ${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 @@
- AAPL · MSFT · SPY · BND · GLD
- 5Y monthly
- Not run yet
- Monthly
+
+
+
+ Monthly
@@ -29,24 +29,24 @@
Universe
-
-
AAPL
-
MSFT
-
GOOGL
-
AMZN
-
NVDA
-
SPY
-
QQQ
-
BND
-
GLD
-
VNQ
+
+ AAPL
+ MSFT
+ GOOGL
+ AMZN
+ NVDA
+ SPY
+ QQQ
+ BND
+ GLD
+ VNQ
-
5 of 10 selected · minimum 2
+
5 of 10 selected · minimum 2
History window
-
-
Run analysis
+
Run analysis
01 Efficient frontier
@@ -82,15 +82,28 @@ History window
-
-
Coming soon
-
- The report (efficient frontier, allocation, correlation matrix, per-asset statistics and saved
- runs) is being rebuilt section by section — see the implementation plan for the staged PRs ahead.
-
-
+
+
+
+
+
+
+ Expected returns and covariances are estimated from historical
+ monthly closes and are not forecasts. Long-only, fully invested, no transaction costs or taxes.
+ MarkoWizard · Outliers Analytics.
+
+
+
+
+
+
Solving 60 quadratic programs
+
+
+
+
+