From 66f9bc79b621c42520810b4176f040db3670e229 Mon Sep 17 00:00:00 2001 From: Escelit Date: Sat, 30 May 2026 10:40:20 +0100 Subject: [PATCH 1/2] feat: Safe Max button with configurable HF floor (#6) - Add Safe Max button next to leverage slider; snaps to highest leverage where projected HF >= user-configured floor - Add HF floor input in settings dropdown (default 1.2, min 1.01) - Persist floor value in localStorage under safeMaxHfFloor key --- frontend/index.html | 5 +++++ frontend/src/main.ts | 48 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/frontend/index.html b/frontend/index.html index f904f23..87163c1 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -55,6 +55,10 @@

Important Disclaimer

@@ -396,6 +400,7 @@

Open Position

× +
Conservative diff --git a/frontend/src/main.ts b/frontend/src/main.ts index fcc1ceb..8d1deda 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -324,6 +324,21 @@ const MIN_HF_NORMAL = 1.01; const MIN_HF_EXPERT = 1.00001; function minHF() { return expertMode ? MIN_HF_EXPERT : MIN_HF_NORMAL; } +// ── Safe Max HF floor ──────────────────────────────────────────────────────── + +const HF_FLOOR_KEY = "safeMaxHfFloor"; +const HF_FLOOR_DEFAULT = 1.2; + +function getHfFloor(): number { + const raw = localStorage.getItem(HF_FLOOR_KEY); + const v = raw ? parseFloat(raw) : HF_FLOOR_DEFAULT; + return isFinite(v) && v >= 1.01 ? v : HF_FLOOR_DEFAULT; +} + +function setHfFloor(v: number) { + localStorage.setItem(HF_FLOOR_KEY, String(v)); +} + // ── Demo mode ──────────────────────────────────────────────────────────────── let demoMode = false; @@ -2709,6 +2724,39 @@ $("vault-rebalance-btn").addEventListener("click", async () => { } }); +// ── Safe Max button ─────────────────────────────────────────────────────────── + +function applySafeMax() { + const slider = $("leverage-slider") as HTMLInputElement; + const numIn = $("leverage-input") as HTMLInputElement; + const rs = reserves.find(r => r.asset.id === selectedAsset.id); + const c = rs ? rs.cFactor : selectedAsset.cFactor; + const l = rs?.lFactor ?? 1; + const floor = getHfFloor(); + const maxSlider = parseFloat(slider.max); + + // Walk down from slider max in 0.1 steps to find highest leverage with HF >= floor + let best = 1.0; + for (let lev = maxSlider; lev >= 1.0; lev = Math.round((lev - 0.1) * 10) / 10) { + if (hfForLeverage(lev, c, l) >= floor) { best = lev; break; } + } + + slider.value = String(best); + numIn.value = best.toFixed(1); + updatePreview(); +} + +$("safe-max-btn").addEventListener("click", applySafeMax); + +// HF floor input (settings dropdown) +const hfFloorInput = $("hf-floor-input") as HTMLInputElement; +hfFloorInput.value = String(getHfFloor()); +hfFloorInput.addEventListener("change", () => { + const v = parseFloat(hfFloorInput.value); + if (isFinite(v) && v >= 1.01) setHfFloor(v); + else hfFloorInput.value = String(getHfFloor()); // revert invalid +}); + // ── Auto-reconnect saved wallet ────────────────────────────────────────────── (async () => { // Restore network preference From 98bc7fc9118a8666ea652a794a4c2fa3393854f0 Mon Sep 17 00:00:00 2001 From: Escelit Date: Sat, 30 May 2026 10:46:02 +0100 Subject: [PATCH 2/2] feat: sortable position table + compact view toggle (#16) - Sort by asset, leverage, HF, unrealised P&L; second click reverses - Compact view renders a one-row-per-position table; expanded keeps cards - Sort column, direction, and view preference persisted in localStorage - P&L column shows unrealised gain/loss when a PnL entry exists --- frontend/index.html | 13 +++- frontend/src/main.ts | 139 ++++++++++++++++++++++++++++++++++------- frontend/src/style.css | 29 +++++++++ 3 files changed, 158 insertions(+), 23 deletions(-) diff --git a/frontend/index.html b/frontend/index.html index 87163c1..d9cca71 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -276,7 +276,18 @@

Portfolio Overview

- +
diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 8d1deda..0711142 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -844,33 +844,110 @@ function computePoolHF(): number { return totalDebt > 0 ? weightedCollateral / totalDebt : Infinity; } -// ── Portfolio summary (#8) ─────────────────────────────────────────────────── +// ── Portfolio summary (#8) — sortable + compact/expanded (#16) ─────────────── + +type SortCol = "asset" | "leverage" | "hf" | "pnl"; +type SortDir = "asc" | "desc"; + +const POS_SORT_KEY = "posSortCol"; +const POS_DIR_KEY = "posSortDir"; +const POS_VIEW_KEY = "posViewCompact"; + +let posSortCol: SortCol = (localStorage.getItem(POS_SORT_KEY) as SortCol) || "asset"; +let posSortDir: SortDir = (localStorage.getItem(POS_DIR_KEY) as SortDir) || "asc"; +let posViewCompact: boolean = localStorage.getItem(POS_VIEW_KEY) === "1"; + +function savePosPrefs() { + localStorage.setItem(POS_SORT_KEY, posSortCol); + localStorage.setItem(POS_DIR_KEY, posSortDir); + localStorage.setItem(POS_VIEW_KEY, posViewCompact ? "1" : "0"); +} function renderPortfolioSummary() { + const wrap = $("portfolio-summary-wrap"); const container = $("portfolio-summary"); - if (positions.byAsset.size === 0) { container.classList.add("hidden"); return; } - container.classList.remove("hidden"); - container.innerHTML = ""; - for (const [assetId, pos] of positions.byAsset) { - const rs = reserves.find(r => r.asset.id === assetId); + if (positions.byAsset.size === 0) { wrap.classList.add("hidden"); return; } + wrap.classList.remove("hidden"); + + // Build sortable rows + const rows = Array.from(positions.byAsset.entries()).map(([assetId, pos]) => { + const rs = reserves.find(r => r.asset.id === assetId); const cardNetApr = rs ? rs.netSupplyApr * pos.leverage - rs.netBorrowCost * (pos.leverage - 1) : 0; - const netApy = aprToApy(cardNetApr); - const hfColor = pos.hf > 1.1 ? "var(--success)" : pos.hf > 1.03 ? "var(--warning)" : "var(--danger)"; - const card = document.createElement("div"); - card.className = `portfolio-card ${assetId === selectedAsset.id ? "active" : ""}`; - card.title = `Approximate APY — Blend does not auto-compound. Actual net APR: ${fmt(cardNetApr, 2)}%`; - card.innerHTML = ` - - ${pos.asset.symbol} - - ${fmt(pos.equity, 2)} equity \u00B7 ${fmt(pos.leverage, 1)}\u00D7 - APY ${netApy >= 0 ? "+" : ""}${fmt(netApy, 2)}% \u00B7 HF ${fmt(pos.hf, 2)} - `; - card.addEventListener("click", () => { - const asset = assets.find(a => a.id === assetId); - if (asset) selectAsset(asset); + const netApy = aprToApy(cardNetApr); + const pnlEntry = getPnlEntry(assetId, selectedPool.id); + const pnl = pnlEntry ? pos.equity - pnlEntry.deposit : 0; + return { assetId, pos, rs, netApy, pnl, cardNetApr }; + }); + + rows.sort((a, b) => { + let diff = 0; + if (posSortCol === "asset") diff = a.pos.asset.symbol.localeCompare(b.pos.asset.symbol); + else if (posSortCol === "leverage") diff = a.pos.leverage - b.pos.leverage; + else if (posSortCol === "hf") diff = (isFinite(a.pos.hf) ? a.pos.hf : 999) - (isFinite(b.pos.hf) ? b.pos.hf : 999); + else if (posSortCol === "pnl") diff = a.pnl - b.pnl; + return posSortDir === "asc" ? diff : -diff; + }); + + // Update sort button indicators + document.querySelectorAll(".pos-sort-btn").forEach(btn => { + const col = btn.dataset.col as SortCol; + const arrow = btn.querySelector(".sort-arrow")!; + btn.classList.toggle("active", col === posSortCol); + arrow.textContent = col !== posSortCol ? "↕" : posSortDir === "asc" ? "↑" : "↓"; + }); + + // Update view toggle icon + ($("pos-view-toggle") as HTMLButtonElement).innerHTML = posViewCompact ? "☷" : "☷"; + $("portfolio-summary").classList.toggle("portfolio-summary-compact", posViewCompact); + + if (posViewCompact) { + // Compact: one-row table + container.innerHTML = ` + + + + ${rows.map(({ assetId, pos, netApy, pnl }) => { + const hfCls = pos.hf > 1.1 ? "hf-ok" : pos.hf > 1.03 ? "hf-warn" : "hf-bad"; + const pnlCls = pnl >= 0 ? "hf-ok" : "hf-bad"; + const pnlStr = pnl !== 0 ? `${pnl >= 0 ? "+" : ""}${fmt(pnl, 4)}` : "—"; + return ` + + + + + + + `; + }).join("")} +
AssetEquityLevHFAPYP&L
${pos.asset.symbol}${fmt(pos.equity, 2)}${fmt(pos.leverage, 1)}×${isFinite(pos.hf) ? fmt(pos.hf, 3) : "∞"}${netApy >= 0 ? "+" : ""}${fmt(netApy, 2)}%${pnlStr}
`; + container.querySelectorAll(".pos-table-row").forEach(row => { + row.addEventListener("click", () => { + const asset = assets.find(a => a.id === row.dataset.asset); + if (asset) selectAsset(asset); + }); + }); + } else { + // Expanded: cards + container.innerHTML = ""; + rows.forEach(({ assetId, pos, netApy, pnl, cardNetApr }) => { + const hfColor = pos.hf > 1.1 ? "var(--success)" : pos.hf > 1.03 ? "var(--warning)" : "var(--danger)"; + const pnlStr = pnl !== 0 ? ` · P&L ${pnl >= 0 ? "+" : ""}${fmt(pnl, 4)}` : ""; + const card = document.createElement("div"); + card.className = `portfolio-card ${assetId === selectedAsset.id ? "active" : ""}`; + card.title = `Approximate APY — Blend does not auto-compound. Actual net APR: ${fmt(cardNetApr, 2)}%`; + card.innerHTML = ` + + ${pos.asset.symbol} + + ${fmt(pos.equity, 2)} equity · ${fmt(pos.leverage, 1)}× + APY ${netApy >= 0 ? "+" : ""}${fmt(netApy, 2)}% · HF ${fmt(pos.hf, 2)}${pnlStr} + `; + card.addEventListener("click", () => { + const asset = assets.find(a => a.id === assetId); + if (asset) selectAsset(asset); + }); + container.appendChild(card); }); - container.appendChild(card); } } @@ -2008,6 +2085,24 @@ function initTooltips() { // ── Event listeners ─────────────────────────────────────────────────────────── +// Sort buttons +document.querySelectorAll(".pos-sort-btn").forEach(btn => { + btn.addEventListener("click", () => { + const col = btn.dataset.col as SortCol; + if (posSortCol === col) posSortDir = posSortDir === "asc" ? "desc" : "asc"; + else { posSortCol = col; posSortDir = "asc"; } + savePosPrefs(); + renderPortfolioSummary(); + }); +}); + +// Compact / expanded toggle +$("pos-view-toggle").addEventListener("click", () => { + posViewCompact = !posViewCompact; + savePosPrefs(); + renderPortfolioSummary(); +}); + // Expert toggle (settings dropdown) function toggleExpert() { expertMode = !expertMode; diff --git a/frontend/src/style.css b/frontend/src/style.css index 0d4348f..afbb1ef 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -352,10 +352,39 @@ main { flex: 1; max-width: 1200px; width: 100%; margin: 0 auto; padding: 20px 24 /* ── Portfolio summary ───────────────────────────────────────────────────── */ +.portfolio-summary-header { + display: flex; align-items: center; justify-content: space-between; + margin-bottom: 6px; +} +.portfolio-sort-btns { display: flex; gap: 4px; } +.pos-sort-btn { + padding: 2px 8px; font-size: 11px; font-weight: 600; border-radius: var(--r); + background: transparent; border: 1px solid var(--border); color: var(--text-2); + cursor: pointer; font-family: var(--sans); transition: all .15s; +} +.pos-sort-btn:hover { border-color: var(--border-h); color: var(--text); } +.pos-sort-btn.active { border-color: var(--primary); color: var(--primary); } +.sort-arrow { font-size: 10px; } + .portfolio-summary { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 4px; margin-bottom: 14px; scrollbar-width: thin; } +.portfolio-summary.portfolio-summary-compact { display: block; } + +/* Compact table */ +.pos-table { width: 100%; border-collapse: collapse; font-size: 12px; margin-bottom: 14px; } +.pos-table th { + text-align: left; padding: 4px 8px; font-size: 10px; font-weight: 600; + text-transform: uppercase; letter-spacing: .4px; color: var(--text-3); + border-bottom: 1px solid var(--border); +} +.pos-table td { padding: 5px 8px; border-bottom: 1px solid var(--border); } +.pos-table-row { cursor: pointer; transition: background .12s; } +.pos-table-row:hover { background: var(--surface); } +.pos-table-row.active { background: var(--tab-active-bg); } +.fw600 { font-weight: 700; } + .portfolio-card { flex-shrink: 0; display: flex; align-items: center; gap: 10px; padding: 10px 16px; border-radius: var(--r);