diff --git a/frontend/index.html b/frontend/index.html
index f904f23..d9cca71 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -55,6 +55,10 @@
Conservative
diff --git a/frontend/src/main.ts b/frontend/src/main.ts
index fcc1ceb..0711142 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;
@@ -829,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 = `
+
+ | Asset | Equity | Lev | HF | APY | P&L |
+
+ ${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 `
+ | ${pos.asset.symbol} |
+ ${fmt(pos.equity, 2)} |
+ ${fmt(pos.leverage, 1)}× |
+ ${isFinite(pos.hf) ? fmt(pos.hf, 3) : "∞"} |
+ ${netApy >= 0 ? "+" : ""}${fmt(netApy, 2)}% |
+ ${pnlStr} |
+
`;
+ }).join("")}
+
`;
+ 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);
}
}
@@ -1993,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;
@@ -2709,6 +2819,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
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);