Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ <h2 id="disclaimer-title">Important Disclaimer</h2>
<div id="settings-dropdown" class="settings-dropdown hidden">
<button id="expert-toggle" class="settings-dropdown-item">Expert Mode <span class="settings-badge">Off</span></button>
<button id="theme-toggle" class="settings-dropdown-item">Theme <span class="settings-badge">&#9790;</span></button>
<div class="settings-dropdown-item settings-hf-floor">
<label for="hf-floor-input">Safe Max HF floor</label>
<input type="number" id="hf-floor-input" class="input mono" min="1.01" max="3" step="0.01" value="1.2" style="width:5rem" />
</div>
</div>
<button id="network-toggle" class="btn btn-ghost btn-sm network-toggle" data-tip="Switch between Mainnet and Testnet">Mainnet</button>
<button id="connect-btn" class="btn btn-primary btn-connect">Connect Wallet</button>
Expand Down Expand Up @@ -272,7 +276,18 @@ <h2>Portfolio Overview</h2>
</div>

<!-- Portfolio summary -->
<div id="portfolio-summary" class="portfolio-summary hidden"></div>
<div id="portfolio-summary-wrap" class="hidden">
<div class="portfolio-summary-header">
<div class="portfolio-sort-btns">
<button class="pos-sort-btn active" data-col="asset">Asset <span class="sort-arrow">↕</span></button>
<button class="pos-sort-btn" data-col="leverage">Lev <span class="sort-arrow">↕</span></button>
<button class="pos-sort-btn" data-col="hf">HF <span class="sort-arrow">↕</span></button>
<button class="pos-sort-btn" data-col="pnl">P&amp;L <span class="sort-arrow">↕</span></button>
</div>
<button id="pos-view-toggle" class="btn btn-ghost btn-sm" data-tip="Toggle compact / expanded view">&#9783;</button>
</div>
<div id="portfolio-summary" class="portfolio-summary"></div>
</div>

<!-- Pool stats (visible by default) -->
<div id="stats-collapsible" class="stats-collapsible">
Expand Down Expand Up @@ -396,6 +411,7 @@ <h2 id="action-card-title">Open Position</h2>
<input type="range" id="leverage-slider" min="1.1" max="12.9" step="0.1" value="2.0" class="slider" />
<input type="number" id="leverage-input" class="input mono leverage-num-input" min="1.1" max="12.9" step="0.1" value="2.0" />
<span class="slider-value-x mono">&times;</span>
<button id="safe-max-btn" class="btn btn-ghost btn-sm" data-tip="Set leverage to the highest value where projected HF ≥ your configured floor">Safe Max</button>
</div>
<div class="slider-zones" id="slider-zones">
<span class="slider-zone zone-conservative" data-zone="conservative">Conservative</span>
Expand Down
187 changes: 165 additions & 22 deletions frontend/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 = `
<span class="portfolio-card-hf-dot" style="background:${hfColor};box-shadow:0 0 6px ${hfColor}"></span>
<span class="portfolio-card-symbol">${pos.asset.symbol}</span>
<span class="portfolio-card-details">
<span>${fmt(pos.equity, 2)} equity \u00B7 ${fmt(pos.leverage, 1)}\u00D7</span>
<span>APY ${netApy >= 0 ? "+" : ""}${fmt(netApy, 2)}% \u00B7 HF ${fmt(pos.hf, 2)}</span>
</span>`;
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<HTMLButtonElement>(".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 ? "&#9783;" : "&#9783;";
$("portfolio-summary").classList.toggle("portfolio-summary-compact", posViewCompact);

if (posViewCompact) {
// Compact: one-row table
container.innerHTML = `<table class="pos-table">
<thead><tr>
<th>Asset</th><th>Equity</th><th>Lev</th><th>HF</th><th>APY</th><th>P&L</th>
</tr></thead>
<tbody>${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 `<tr class="pos-table-row ${assetId === selectedAsset.id ? "active" : ""}" data-asset="${assetId}">
<td class="mono fw600">${pos.asset.symbol}</td>
<td class="mono">${fmt(pos.equity, 2)}</td>
<td class="mono">${fmt(pos.leverage, 1)}×</td>
<td class="mono ${hfCls}">${isFinite(pos.hf) ? fmt(pos.hf, 3) : "∞"}</td>
<td class="mono ${netApy > 0 ? "hf-ok" : "hf-bad"}">${netApy >= 0 ? "+" : ""}${fmt(netApy, 2)}%</td>
<td class="mono ${pnlCls}">${pnlStr}</td>
</tr>`;
}).join("")}</tbody>
</table>`;
container.querySelectorAll<HTMLElement>(".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 = `
<span class="portfolio-card-hf-dot" style="background:${hfColor};box-shadow:0 0 6px ${hfColor}"></span>
<span class="portfolio-card-symbol">${pos.asset.symbol}</span>
<span class="portfolio-card-details">
<span>${fmt(pos.equity, 2)} equity · ${fmt(pos.leverage, 1)}×</span>
<span>APY ${netApy >= 0 ? "+" : ""}${fmt(netApy, 2)}% · HF ${fmt(pos.hf, 2)}${pnlStr}</span>
</span>`;
card.addEventListener("click", () => {
const asset = assets.find(a => a.id === assetId);
if (asset) selectAsset(asset);
});
container.appendChild(card);
});
container.appendChild(card);
}
}

Expand Down Expand Up @@ -1993,6 +2085,24 @@ function initTooltips() {

// ── Event listeners ───────────────────────────────────────────────────────────

// Sort buttons
document.querySelectorAll<HTMLButtonElement>(".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;
Expand Down Expand Up @@ -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
Expand Down
29 changes: 29 additions & 0 deletions frontend/src/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading