From 060bb2ea6303959c0912f4ac7d71ec117b69ce3a Mon Sep 17 00:00:00 2001 From: GusFurtado Date: Fri, 11 Sep 2026 18:19:27 -0300 Subject: [PATCH 1/5] Wire up the control rail and KPI band against the real API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit frontend/app.js (new): a pending/applied state machine — sel, period and rfIdx are what the rail shows; appliedSel/appliedPeriod/appliedRfIdx are what the current result was actually solved from. They start equal (the app auto-runs once on load with the default universe, so there's always a report on screen rather than an empty first-load state) and diverge the moment a control changes, which is what flips the run button between "Run analysis" and "Re-run analysis". Wired up: - Universe chips (toggle, minimum-2 floor enforced as a no-op below it) - History window segment + risk-free slider (live-updating display) - Run -> POST /api/analyze, running overlay, and an error card with a Retry action for 400/500s (no design exists for this yet — see the handoff's own Gaps list — so this reuses the card primitive plus the semantic error color rather than inventing new visual language) - Header tags/status/units toggle, reflecting the applied run once one exists - The KPI band: live headline/lede and the 5 KPI cards, monthly/annual unit conversion threaded through all of them The frontier selection (iF) and cash blend aren't wired yet — both default to "no selection" (tangency, 0% cash), so the KPI band always reads max_sharpe_portfolio for now. Both land with their own sections in upcoming PRs and this file already reads through that same state shape so they slot in without a rework. Also: .mw-overlay's own `display: grid` outranked the UA stylesheet's `[hidden]{display:none}` at equal specificity (source order), so a "hidden" overlay still intercepted clicks — found by a headless-browser click test, fixed with an explicit `.mw-overlay[hidden]{display:none}`. Verified with a headless Chromium against a mocked /api/analyze (matching how tests/test_api.py mocks fetch_prices): happy path KPI values, monthly<->annual conversion, the chip minimum-2 floor, and the error/retry path all checked by script, plus visual screenshots. Co-Authored-By: Claude Sonnet 5 --- frontend/app.js | 279 ++++++++++++++++++++++++++++++++++++++++++++ frontend/index.html | 55 +++++---- frontend/style.css | 96 +++++++++++++++ 3 files changed, 409 insertions(+), 21 deletions(-) create mode 100644 frontend/app.js diff --git a/frontend/app.js b/frontend/app.js new file mode 100644 index 0000000..8063fb1 --- /dev/null +++ b/frontend/app.js @@ -0,0 +1,279 @@ +/** + * MarkoWizard — analytical workstation. + * + * Owns the control-rail state (universe, history window, risk-free rate) and + * the KPI band. The rest of the report (frontier, allocation, correlation, + * per-asset statistics, saved runs) lands in later PRs and will read from + * the same `state` object this file sets up. + * + * 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 + +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). Wired up in a later PR. + 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 isStale() { + return ( + state.sel.join() !== state.appliedSel.join() || + state.period !== state.appliedPeriod || + state.rfIdx !== state.appliedRfIdx + ); +} + +/** The portfolio the KPI band (and, later, the rest of the report) reads + * from. `iF` isn't selectable yet (that's the frontier chart's job, PR 4), + * so this always resolves to the tangency portfolio for now. */ +function selectedPortfolio() { + if (!state.result) return null; + if (state.iF == null) return state.result.max_sharpe_portfolio; + return state.result.efficient_frontier[state.iF]; +} + +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 headline = `The best risk-adjusted mix of your ${n} asset${n === 1 ? "" : "s"}`; + 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. 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 kpis = [ + { label: "Expected return", value: pct(toReturn(p.expected_return)), note: unitWord }, + { label: "Volatility", value: pct(toVol(p.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)}

+ +
`; +} + +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(); +} + +/* ── 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: both the rail's Run button and the error card's Retry button + // carry data-action="run" — the latter only exists after a re-render, so + // it can't have a listener attached directly. + document.body.addEventListener("click", (e) => { + if (e.target.closest('[data-action="run"]')) runAnalysis(); + }); + + render(); + runAnalysis(); +} + +document.addEventListener("DOMContentLoaded", init); diff --git a/frontend/index.html b/frontend/index.html index 86bac8f..7c7f477 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..431ed42 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,91 @@ 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; +} From 5139dffd8f8319abf83d123b82fe7a361de57a82 Mon Sep 17 00:00:00 2001 From: GusFurtado Date: Fri, 11 Sep 2026 18:39:29 -0300 Subject: [PATCH 2/5] Efficient frontier: SVG chart with drag selection, per-asset dots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The signature interaction of the whole redesign: an SVG chart in absolute risk/return space (both axes start at zero, not min-max normalized) with the frontier curve, single-asset points, the capital allocation line, minimum-variance/tangency markers, and a selection that tracks pointer drag 1:1, plus a stats panel that follows it. frontend/app.js: - resolveSelection(): the shared "which frontier point is selected, and what do we call it" logic, used by both the frontier stats panel and the KPI headline (which no longer hardcodes the tangency copy — it now shows the same position-relative label the frontier does when a non-default point is selected). - computeFrontierGeometry(): scale functions and point coordinates, derived from the real /api/analyze response — no client-side solver, per the handoff ("do not port the prototype's solver"). - Base vs. selection rendering split: the SVG and its static layers (grid, ticks, frontier curve, CAL, per-asset dots, min-var/tangency markers) are only rebuilt when the result or the units changes; dragging only updates the crosshair/marker attributes and the stats panel text. This matters because pointerdown captures the pointer on the SVG element itself — rebuilding it mid-drag (e.g. from an innerHTML replace on every pointermove) would silently drop that capture and break the gesture. - pickFrontierPoint(): maps a pointer x-position to the nearest frontier point by risk, accounting for the plot box's left inset rather than a naive fraction of element width — same as the handoff. Two real bugs found by headless-browser testing, not by inspection: - Toggling units (monthly/annual) didn't update the chart's axis titles or tick labels, only the stats panel — because those are part of the "base" HTML, which is keyed only on the result reference, and toggling units doesn't change that reference. Fixed by keying the base rebuild on (result, units) together. Safe to do because a units toggle is a discrete click, never a pointermove mid-drag. - (Carried a class of risk in from PR 3, re-confirmed here): any bug that forces a full base rebuild during a drag would drop pointer capture. Verified this isn't happening by dragging, then toggling units, and confirming the selection survives the rebuild unchanged. Verified with headless Chromium against a synthetic /api/analyze response shaped like a real efficient frontier (concave, tangency mid-curve): mouse-drag selection, the Min variance / Max Sharpe jump buttons, and the monthly/annual toggle, each checked against the rendered DOM plus screenshots. Co-Authored-By: Claude Sonnet 5 --- frontend/app.js | 357 ++++++++++++++++++++++++++++++++++++++++++-- frontend/index.html | 6 +- frontend/style.css | 87 +++++++++++ 3 files changed, 433 insertions(+), 17 deletions(-) diff --git a/frontend/app.js b/frontend/app.js index 8063fb1..19f7768 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -1,10 +1,10 @@ /** * MarkoWizard — analytical workstation. * - * Owns the control-rail state (universe, history window, risk-free rate) and - * the KPI band. The rest of the report (frontier, allocation, correlation, - * per-asset statistics, saved runs) lands in later PRs and will read from - * the same `state` object this file sets up. + * Owns the control-rail state (universe, history window, risk-free rate), + * the KPI band, and the efficient-frontier chart. The rest of the report + * (allocation, correlation, per-asset statistics, saved runs) lands in + * later PRs and will read from the same `state` object this file sets up. * * No framework, no build step — plain DOM, matching the rest of the repo. */ @@ -24,6 +24,13 @@ const UNIVERSE = [ 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", @@ -44,8 +51,8 @@ const state = { appliedSel: [...DEFAULT_SEL], appliedPeriod: "5y", appliedRfIdx: 8, - iF: null, // frontier selection; null = tangency (max Sharpe). Wired up in a later PR. - cash: 0, // cash blend; wired up in a later PR. + 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, @@ -76,6 +83,21 @@ function escapeHtml(s) { "&": "&", "<": "<", ">": ">", '"': """, "'": "'", })[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 ( @@ -85,13 +107,35 @@ function isStale() { ); } +/** 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. `iF` isn't selectable yet (that's the frontier chart's job, PR 4), - * so this always resolves to the tangency portfolio for now. */ + * from. */ function selectedPortfolio() { if (!state.result) return null; - if (state.iF == null) return state.result.max_sharpe_portfolio; - return state.result.efficient_frontier[state.iF]; + const { iF } = resolveSelection(state.result); + return state.result.efficient_frontier[iF]; } async function runAnalysis() { @@ -172,7 +216,8 @@ function kpiSectionHtml() { const unitWord = isAnnual() ? "annualized" : "monthly"; const n = state.appliedSel.length; - const headline = `The best risk-adjusted mix of your ${n} asset${n === 1 ? "" : "s"}`; + 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)))} ` + @@ -215,6 +260,281 @@ function errorCardHtml() {
`; } +/* ── 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(); +} + function renderKpiSlot() { const slot = document.getElementById("mw-kpi-slot"); if (state.error) { @@ -231,6 +551,7 @@ function render() { renderChips(); renderOverlay(); renderKpiSlot(); + renderFrontier(); } /* ── Event wiring ────────────────────────────────────────────────────── */ @@ -265,11 +586,19 @@ function init() { render(); }); - // Delegated: both the rail's Run button and the error card's Retry button - // carry data-action="run" — the latter only exists after a re-render, so - // it can't have a listener attached directly. + // 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(); + } }); render(); diff --git a/frontend/index.html b/frontend/index.html index 7c7f477..d102831 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -83,12 +83,12 @@
History window
+
Coming soon

- The rest of 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. + The rest of the report (allocation, correlation matrix, per-asset statistics and saved runs) is + being rebuilt section by section — see the implementation plan for the staged PRs ahead.

diff --git a/frontend/style.css b/frontend/style.css index 431ed42..cddf56b 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -612,3 +612,90 @@ input[type="range"]:hover::-moz-range-thumb { 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; +} From a184b97dee8ab45fd66d936adf3c811c451916bd Mon Sep 17 00:00:00 2001 From: GusFurtado Date: Fri, 11 Sep 2026 18:48:08 -0300 Subject: [PATCH 3/5] Optimal allocation: donut, cash blend, and the two-frame weights table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit frontend/app.js: - The donut (stroke-dasharray arcs + an HTML center overlay — SVG at this weight shapes to zero width, per the handoff), the cash-blend slider, and a weights table that keeps two frames of the same numbers clearly separate: "in risky mix" (sums to 100%) and "of capital" (sums to 1 - cash) — a mixing-up of these two was called out in the handoff as a real review bug on this project, so both the percentage *and* the bar-fill width for a row always come from the same frame. - cashBlend(): a plain weighted average of the selected portfolio and cash. This is a deliberate departure from the handoff's own suggestion to prefer the API's capital_allocation_line[] — that array is anchored to the tangency portfolio specifically (that's what makes it a capital allocation line: blending cash with any other point doesn't dominate the frontier). Now that PR 4 lets the frontier selection move off tangency, the API array can't answer "blend cash with *this* selected point" for a non-tangency selection — but the handoff's own interactive prototype already does exactly this local blend for whatever point is selected, so this follows the prototype's actual behavior over the README's simplifying prose. - Fixed a real inconsistency this surfaced: the KPI band's Expected return/Volatility are supposed to be the cash-blended figures (only Sharpe is unaffected by cash, per its own "unchanged by the cash blend" note) — PR 3 had them reading the raw portfolio figures instead, which happened to be harmless while cash was always 0. Fixed now that cash is live, using the same cashBlend() helper. - Same base/update split as the frontier chart, for the same reason: the cash `` fires `input` continuously while being dragged, and rebuilding it mid-drag (e.g. from an innerHTML replace on every tick) would interrupt the browser's own drag gesture on it. Only the row identity (tickers/names/colors) is "base"; every number, bar width, and the donut are refreshed on every render without touching the slider element itself. frontend/style.css: adds the shared "not a " grid-row primitives (.mw-table, .mw-grid-row, .mw-swatch, .mw-bar-track/.mw-bar-fill) that the correlation, per-asset-statistics and saved-runs sections will reuse in upcoming PRs, plus the allocation-specific layout. Verified with headless Chromium against a synthetic /api/analyze response: donut/table/KPI band agree at cash=0% and cash=50% (checked the exact blended numbers, not just that something rendered), the cash-row and per-asset rows sum correctly in both frames, and blending cash off a dragged (non-tangency) frontier selection reads that selection's own figures rather than silently falling back to tangency. Co-Authored-By: Claude Sonnet 5 --- frontend/app.js | 198 ++++++++++++++++++++++++++++++++++++++++++-- frontend/index.html | 5 +- frontend/style.css | 147 ++++++++++++++++++++++++++++++++ 3 files changed, 342 insertions(+), 8 deletions(-) diff --git a/frontend/app.js b/frontend/app.js index 19f7768..00eabea 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -2,9 +2,10 @@ * MarkoWizard — analytical workstation. * * Owns the control-rail state (universe, history window, risk-free rate), - * the KPI band, and the efficient-frontier chart. The rest of the report - * (allocation, correlation, per-asset statistics, saved runs) lands in - * later PRs and will read from the same `state` object this file sets up. + * the KPI band, the efficient-frontier chart, and the allocation donut/cash + * blend/weights table. The rest of the report (correlation, per-asset + * statistics, saved runs) lands in later PRs and will read from the same + * `state` object this file sets up. * * No framework, no build step — plain DOM, matching the rest of the repo. */ @@ -138,6 +139,26 @@ function selectedPortfolio() { 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; @@ -221,15 +242,19 @@ function kpiSectionHtml() { 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. Fully invested in the risky portfolio.`; + `${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(p.expected_return)), note: unitWord }, - { label: "Volatility", value: pct(toVol(p.risk)), note: "standard deviation" }, + { 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" }, @@ -535,6 +560,166 @@ function renderFrontier() { 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(); +} + function renderKpiSlot() { const slot = document.getElementById("mw-kpi-slot"); if (state.error) { @@ -552,6 +737,7 @@ function render() { renderOverlay(); renderKpiSlot(); renderFrontier(); + renderAllocation(); } /* ── Event wiring ────────────────────────────────────────────────────── */ diff --git a/frontend/index.html b/frontend/index.html index d102831..5afe830 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -84,11 +84,12 @@
History window
+
Coming soon

- The rest of the report (allocation, correlation matrix, per-asset statistics and saved runs) is - being rebuilt section by section — see the implementation plan for the staged PRs ahead. + The rest of the report (correlation matrix, per-asset statistics and saved runs) is being + rebuilt section by section — see the implementation plan for the staged PRs ahead.

diff --git a/frontend/style.css b/frontend/style.css index cddf56b..ec7333b 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -699,3 +699,150 @@ input[type="range"]:hover::-moz-range-thumb { 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%; +} From c3c57b59ab4dcc9cb32bac0ea73b527a27757341 Mon Sep 17 00:00:00 2001 From: GusFurtado Date: Fri, 11 Sep 2026 19:10:03 -0300 Subject: [PATCH 4/5] Correlation matrix and per-asset statistics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both bundled in one PR since they share the grid-row table pattern PR 5 already built (.mw-table/.mw-grid-row/.mw-num), and neither has any interactive state of its own — no drag, no slider — so both are plain full-rebuild-on-render sections, unlike frontier/allocation. frontend/app.js: - correlationSectionHtml(): a CSS-grid matrix (not a
, matching the rest of the report), cell color linearly interpolated in RGB from neutral-900 to accent-600. Text stays ink-dark at every value — this ramp (unlike the mobile design's) never needs a light/dark contrast threshold, since it tops out light enough that dark text stays correct throughout. The least/most-correlated-pair callout scans only the upper triangle so each pair is considered once. - assetStatsSectionHtml(): standalone per-asset expected return/ volatility (from asset_statistics — independent of any portfolio), a client-computed standalone Sharpe, and the Weight column reads through selectedPortfolio() — so, matching the handoff, it updates live as the frontier selection is dragged, not just at the tangency default. Zero/near-zero weights dim to neutral-600 rather than being hidden, so an asset the optimizer declined stays visible. Verified with headless Chromium against a synthetic /api/analyze response: the matrix diagonal is 1.00 everywhere, the least/most correlated pairs match a manual scan of the input matrix, and the per-asset Weight column changes when jumping to the minimum-variance point on the frontier (confirming it tracks selection, not a fixed snapshot) — plus a full-page screenshot checked against the handoff. Co-Authored-By: Claude Sonnet 5 --- frontend/app.js | 149 ++++++++++++++++++++++++++++++++++++++++++-- frontend/index.html | 5 +- frontend/style.css | 59 ++++++++++++++++++ 3 files changed, 207 insertions(+), 6 deletions(-) diff --git a/frontend/app.js b/frontend/app.js index 00eabea..4d123e2 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -2,10 +2,10 @@ * MarkoWizard — analytical workstation. * * Owns the control-rail state (universe, history window, risk-free rate), - * the KPI band, the efficient-frontier chart, and the allocation donut/cash - * blend/weights table. The rest of the report (correlation, per-asset - * statistics, saved runs) lands in later PRs and will read from the same - * `state` object this file sets up. + * the KPI band, the efficient-frontier chart, the allocation donut/cash + * blend/weights table, the correlation matrix, and per-asset statistics. + * Saved runs lands in a later PR and will read from the same `state` + * object this file sets up. * * No framework, no build step — plain DOM, matching the rest of the repo. */ @@ -720,6 +720,145 @@ function renderAllocation() { 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) : ""; +} + function renderKpiSlot() { const slot = document.getElementById("mw-kpi-slot"); if (state.error) { @@ -738,6 +877,8 @@ function render() { renderKpiSlot(); renderFrontier(); renderAllocation(); + renderCorrelation(); + renderAssetStats(); } /* ── Event wiring ────────────────────────────────────────────────────── */ diff --git a/frontend/index.html b/frontend/index.html index 5afe830..b277084 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -85,11 +85,12 @@
History window
+
+
Coming soon

- The rest of the report (correlation matrix, per-asset statistics and saved runs) is being - rebuilt section by section — see the implementation plan for the staged PRs ahead. + Saved runs is being rebuilt next — see the implementation plan for the staged PRs ahead.

diff --git a/frontend/style.css b/frontend/style.css index ec7333b..4eaacd6 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -846,3 +846,62 @@ input[type="range"]:hover::-moz-range-thumb { .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; +} From 25891ff71b6fb9cc2a9d4fe982860e0f7dc139ab Mon Sep 17 00:00:00 2001 From: Gustavo Furtado <62435505+GusFurtado@users.noreply.github.com> Date: Fri, 11 Sep 2026 20:18:50 -0300 Subject: [PATCH 5/5] Saved runs: the last report section, plus the closing disclaimer (#17) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is the section the handoff explicitly leaves undesigned ("saving is not implemented — the three rows are fixtures... see Gaps"). Per the earlier agreed decision, v1 persists to this browser's localStorage — no backend changes, no new endpoints. Everything in the "Saved runs" comment block in frontend/app.js is this project's own invention, not from the handoff: - A "Save this run" button (disabled until a run has succeeded), prompting for a name pre-filled with a sensible default (`"{n} assets · {period}"`) so accepting the default is a single Enter. - Storage as a JSON array under `markowizard.savedRuns`, one entry per save: the reload parameters (sel/period/rfIdx) plus the raw (pre-cash) selected-portfolio figures at save time. Reads/writes are wrapped in try/catch — private browsing or a full quota degrades to "didn't persist" rather than breaking the report. - Load replaces sel/period/rfIdx from the saved entry (discarding any unrelated pending edits in the rail) and re-runs the analysis, same as the handoff describes ("loading a run replaces the universe, window and selection"). - Delete, and an empty state for first-time users — neither specified, but both necessary for this to be a usable feature rather than a write-only list. The saved-runs list itself isn't gated on the current result the way every other section is: it stays visible and usable (Load included) even after a failed re-run, so a bad refresh doesn't strand the user away from a previously-good report. Also adds the closing disclaimer paragraph from the handoff's "Disclaimer" spec, the one static piece of the report not yet in the tree. With this PR every section in the layout shell (#12) is now built: the widescreen report redesign is complete end to end. Verified with headless Chromium: save (with a custom name) persists to localStorage with the correct reload parameters; loading a saved run triggers a fresh /api/analyze call with *that run's* tickers/period even when the rail's pending controls had since been changed to something else; delete and the empty state both render correctly; and cancelling the name prompt saves nothing. Co-authored-by: Claude Sonnet 5 --- frontend/app.js | 161 ++++++++++++++++++++++++++++++++++++++++++-- frontend/index.html | 10 ++- frontend/style.css | 17 +++++ 3 files changed, 177 insertions(+), 11 deletions(-) diff --git a/frontend/app.js b/frontend/app.js index 4d123e2..05799e1 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -1,11 +1,12 @@ /** * MarkoWizard — analytical workstation. * - * Owns the control-rail state (universe, history window, risk-free rate), - * the KPI band, the efficient-frontier chart, the allocation donut/cash - * blend/weights table, the correlation matrix, and per-asset statistics. - * Saved runs lands in a later PR and will read from the same `state` - * object this file sets up. + * 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. */ @@ -859,6 +860,150 @@ function renderAssetStats() { 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) { @@ -879,6 +1024,7 @@ function render() { renderAllocation(); renderCorrelation(); renderAssetStats(); + renderSavedRuns(); } /* ── Event wiring ────────────────────────────────────────────────────── */ @@ -926,6 +1072,11 @@ function init() { 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(); diff --git a/frontend/index.html b/frontend/index.html index b277084..4bc3b9a 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -87,12 +87,10 @@
History window
-
- Coming soon -

- Saved runs is being rebuilt next — 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.

diff --git a/frontend/style.css b/frontend/style.css index 4eaacd6..11c839f 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -905,3 +905,20 @@ input[type="range"]:hover::-moz-range-thumb { 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; +}