From 6285efef4a121d22d99d6c3a28d11347c0da67fc Mon Sep 17 00:00:00 2001 From: Sikkra <159844544+Sikkra@users.noreply.github.com> Date: Tue, 19 May 2026 12:28:57 -0500 Subject: [PATCH] Add fee-adjusted APY controls --- frontend/index.html | 4 ++ frontend/src/blend.ts | 14 ++++++ frontend/src/main.ts | 104 +++++++++++++++++++++++++++++++++++------ frontend/src/style.css | 2 + 4 files changed, 111 insertions(+), 13 deletions(-) diff --git a/frontend/index.html b/frontend/index.html index f904f23..796ec7c 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -425,6 +425,10 @@

Open Position

Est. net APY ?
+
+ Fee drag ? + /yr +
Days to liquidation
diff --git a/frontend/src/blend.ts b/frontend/src/blend.ts index 1c4ad6c..2c2ff8c 100644 --- a/frontend/src/blend.ts +++ b/frontend/src/blend.ts @@ -27,6 +27,8 @@ const RATE_DEC = 1_000_000_000_000n; const SCALAR = 10_000_000n; const SCALAR_F = 10_000_000; const SECONDS_PER_YEAR = 31_536_000; +export const DEFAULT_REBALANCES_PER_YEAR = 12; +export const ESTIMATED_REBALANCE_FEE_XLM = 0.25; export const SUPPLY_COLLATERAL = 2; export const WITHDRAW_COLLATERAL = 3; @@ -714,6 +716,18 @@ export function maxLeverageFor(c: number, l: number = 1, minHF: number = 1.01): return cl >= minHF ? 100 : minHF / (minHF - cl); } +export function estimateFeeDragApr( + equityUsd: number, + leverage: number, + rebalancesPerYear: number, + xlmPriceUsd: number, + feeXlm: number = ESTIMATED_REBALANCE_FEE_XLM, +): number { + if (equityUsd <= 0 || leverage <= 0 || rebalancesPerYear <= 0 || xlmPriceUsd <= 0 || feeXlm <= 0) return 0; + // Annual fee drag APR = rebalances/year * fee(XLM) * XLM/USD * leverage / equity(USD) * 100. + return (rebalancesPerYear * feeXlm * xlmPriceUsd * leverage / equityUsd) * 100; +} + // ── Safety guards ──────────────────────────────────────────────────────────── // // These mitigate the structural risks of leveraged loop positions: diff --git a/frontend/src/main.ts b/frontend/src/main.ts index fcc1ceb..ca8832e 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -44,6 +44,9 @@ import { submitClassicXdr, hfForLeverage, maxLeverageFor, + estimateFeeDragApr, + DEFAULT_REBALANCES_PER_YEAR, + ESTIMATED_REBALANCE_FEE_XLM, type NetworkMode, type AssetInfo, type PoolDef, @@ -103,6 +106,7 @@ async function switchNetwork(net: NetworkMode) { // Switch blend.ts network config setNetwork(net); localStorage.setItem("networkMode", net); + syncRebalanceFrequencyInput(); // Reinitialize wallet kit for new network StellarWalletsKit.init({ @@ -335,6 +339,48 @@ const fmt = (n: number, d = 2) => n.toLocaleString("en-US", { maximumFractionDigits: d, minimumFractionDigits: d }); const aprToApy = (apr: number) => (Math.exp(apr / 100) - 1) * 100; const fmtAddr = (addr: string) => addr.slice(0, 6) + "…" + addr.slice(-4); +const REBALANCES_PER_YEAR_KEY = "rebalanceFrequencyPerYear"; + +function rebalanceFrequencyStorageKey(): string { + return `${REBALANCES_PER_YEAR_KEY}:${getActiveNetwork()}:${userAddress ?? "default"}`; +} + +function getRebalancesPerYear(): number { + const scoped = localStorage.getItem(rebalanceFrequencyStorageKey()); + const legacy = localStorage.getItem(REBALANCES_PER_YEAR_KEY); + const stored = scoped ?? legacy; + const raw = stored === null ? NaN : Number(stored); + return Number.isFinite(raw) && raw >= 0 ? raw : DEFAULT_REBALANCES_PER_YEAR; +} + +function setRebalancesPerYear(value: number) { + const clean = Number.isFinite(value) && value >= 0 ? value : DEFAULT_REBALANCES_PER_YEAR; + localStorage.setItem(rebalanceFrequencyStorageKey(), String(clean)); +} + +function syncRebalanceFrequencyInput() { + const input = document.getElementById("rebalance-frequency-input") as HTMLInputElement | null; + if (input) input.value = String(getRebalancesPerYear()); +} + +function xlmPriceUsd(sourceReserves: ReserveStats[] = reserves): number { + const reserve = sourceReserves.find(r => r.asset.symbol === "XLM"); + return reserve?.priceUsd ?? (selectedAsset.symbol === "XLM" ? sourceReserves.find(r => r.asset.id === selectedAsset.id)?.priceUsd ?? 0 : 0); +} + +function feeAdjustedNetApr( + rawNetApr: number, + equityUsd: number, + leverage: number, + sourceReserves: ReserveStats[] = reserves, +): { netApr: number; feeDragApr: number } { + const feeDragApr = estimateFeeDragApr(equityUsd, leverage, getRebalancesPerYear(), xlmPriceUsd(sourceReserves)); + return { netApr: rawNetApr - feeDragApr, feeDragApr }; +} + +function feeDragTip(rawNetApr: number, feeDragApr: number): string { + return `Actual net APR ${fmt(rawNetApr - feeDragApr, 2)}% = raw net APR ${fmt(rawNetApr, 2)}% - annual fee drag ${fmt(feeDragApr, 2)}%. Formula: rebalances/year x ${ESTIMATED_REBALANCE_FEE_XLM} XLM x XLM price x leverage / equity. Default: ${DEFAULT_REBALANCES_PER_YEAR}/year.`; +} // ── Skeleton loading (#3) ──────────────────────────────────────────────────── @@ -718,7 +764,8 @@ function renderApyChart(rs: ReserveStats | undefined, currentLev: number, equity const steps: { lev: number; apy: number }[] = []; for (let l = 1.0; l <= maxLev; l += 0.2) { const p = projectRates(rs, equity * l - oldSupply, equity * (l - 1) - oldBorrow); - steps.push({ lev: l, apy: aprToApy(p.netSupplyApr * l - p.netBorrowCost * (l - 1)) }); + const rawNetApr = p.netSupplyApr * l - p.netBorrowCost * (l - 1); + steps.push({ lev: l, apy: aprToApy(feeAdjustedNetApr(rawNetApr, equity * rs.priceUsd, l).netApr) }); } if (steps.length < 2) { container.innerHTML = ""; return; } const minApy = Math.min(0, ...steps.map(s => s.apy)); @@ -728,7 +775,8 @@ function renderApyChart(rs: ReserveStats | undefined, currentLev: number, equity const y = (apy: number) => padT + (1 - (apy - minApy) / rangeApy) * (H - padT - padB); const points = steps.map(s => `${x(s.lev).toFixed(1)},${y(s.apy).toFixed(1)}`).join(" "); const curProj = projectRates(rs, equity * currentLev - oldSupply, equity * (currentLev - 1) - oldBorrow); - const curApy = aprToApy(curProj.netSupplyApr * currentLev - curProj.netBorrowCost * (currentLev - 1)); + const curRawNetApr = curProj.netSupplyApr * currentLev - curProj.netBorrowCost * (currentLev - 1); + const curApy = aprToApy(feeAdjustedNetApr(curRawNetApr, equity * rs.priceUsd, currentLev).netApr); const zeroY = y(0); // Position the label above or below the dot to avoid clipping @@ -838,12 +886,14 @@ function renderPortfolioSummary() { container.innerHTML = ""; for (const [assetId, pos] of positions.byAsset) { const rs = reserves.find(r => r.asset.id === assetId); - const cardNetApr = rs ? rs.netSupplyApr * pos.leverage - rs.netBorrowCost * (pos.leverage - 1) : 0; + const rawCardNetApr = rs ? rs.netSupplyApr * pos.leverage - rs.netBorrowCost * (pos.leverage - 1) : 0; + const cardFee = rs ? feeAdjustedNetApr(rawCardNetApr, pos.equity * rs.priceUsd, pos.leverage) : { netApr: rawCardNetApr, feeDragApr: 0 }; + const cardNetApr = cardFee.netApr; 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.title = `Approximate APY - Blend does not auto-compound. ${feeDragTip(rawCardNetApr, cardFee.feeDragApr)}`; card.innerHTML = ` ${pos.asset.symbol} @@ -992,7 +1042,8 @@ function renderPosition() { const netAprEl = $("pos-net-apr"); const heroApyEl = $("hero-net-apy"); if (rs && pos.leverage > 0) { - const posNetApr = rs.netSupplyApr * pos.leverage - rs.netBorrowCost * (pos.leverage - 1); + const rawPosNetApr = rs.netSupplyApr * pos.leverage - rs.netBorrowCost * (pos.leverage - 1); + const { netApr: posNetApr, feeDragApr } = feeAdjustedNetApr(rawPosNetApr, pos.equity * rs.priceUsd, pos.leverage); const netApy = aprToApy(posNetApr); const apyIcon = netApy > 0 ? "\u2713" : "\u2717"; netAprEl.textContent = `${apyIcon} ${netApy >= 0 ? "+" : ""}${fmt(netApy, 2)}%`; @@ -1001,7 +1052,7 @@ function renderPosition() { heroApyEl.textContent = `${netApy >= 0 ? "+" : ""}${fmt(netApy, 2)}%`; heroApyEl.className = `metric-hero-value ${netApy > 0 ? "hf-ok" : "hf-bad"}`; // Tooltips with actual APR - const aprTip = `Approximate APY — Blend interest does not auto-compound. Actual net APR: ${fmt(posNetApr, 2)}%`; + const aprTip = `Approximate APY - Blend interest does not auto-compound. ${feeDragTip(rawPosNetApr, feeDragApr)}`; const posTip = $("pos-net-apr-tip"); if (posTip) posTip.setAttribute("data-tip", aprTip); const heroTip = $("hero-net-apy-tip"); @@ -1196,6 +1247,7 @@ function updatePreview() { $("prev-borrow").textContent = `${fmt(borrow, 2)} ${selectedAsset.symbol}`; $("prev-hf").textContent = isFinite(hf) ? fmt(hf, expertMode ? 5 : 4) : "\u221E"; $("prev-hf").className = hf > 1.1 ? "hf-ok" : hf > 1.03 ? "hf-warn" : "hf-bad"; + $("prev-fee-drag").textContent = "\u2014"; // Borrow headroom: how much more could be borrowed before liquidation if (rs && rs.priceUsd > 0) { @@ -1211,13 +1263,15 @@ function updatePreview() { if (rs) { const proj = projectRates(rs, supply - oldSupply, borrow - oldBorrow); - const netApr = proj.netSupplyApr * lev - proj.netBorrowCost * (lev - 1); + const rawNetApr = proj.netSupplyApr * lev - proj.netBorrowCost * (lev - 1); + const { netApr, feeDragApr } = feeAdjustedNetApr(rawNetApr, equity * rs.priceUsd, lev); const netApy = aprToApy(netApr); $("prev-net-apr").textContent = `${fmt(netApy, 2)}% APY on equity`; $("prev-net-apr").className = `prev-net-apr ${netApy > 0 ? "apr-great" : "apr-bad"}`; + $("prev-fee-drag").textContent = `-${fmt(feeDragApr, 2)}% APR`; const prevTip = $("prev-net-tip"); if (prevTip) prevTip.setAttribute("data-tip", - `Approximate APY — Blend interest does not auto-compound. Actual net APR: ${fmt(netApr, 2)}%`); + `Approximate APY - Blend interest does not auto-compound. ${feeDragTip(rawNetApr, feeDragApr)}`); // Days until liquidation at this leverage (interest-only, no BLND) const spreadPct = proj.interestBorrowApr - proj.interestSupplyApr; @@ -1716,6 +1770,7 @@ async function connect() { } userAddress = result.address; localStorage.setItem("walletAddress", userAddress); + syncRebalanceFrequencyInput(); showConnected(); buildPoolTabs(); buildAssetTabs(); @@ -1736,6 +1791,7 @@ async function switchWallet() { if (!networkOk) return; userAddress = result.address; localStorage.setItem("walletAddress", userAddress); + syncRebalanceFrequencyInput(); $("wallet-address").textContent = fmtAddr(userAddress); reserves = []; positions = { byAsset: new Map() }; @@ -1750,6 +1806,7 @@ async function disconnect() { await StellarWalletsKit.disconnect(); userAddress = null; localStorage.removeItem("walletAddress"); + syncRebalanceFrequencyInput(); reserves = []; positions = { byAsset: new Map() }; $("connect-btn").classList.remove("hidden"); @@ -2222,6 +2279,15 @@ async function refreshAddFundsBalance() { slider.value = v.toFixed(1); updatePreview(); }); +const rebalanceFrequencyInput = $("rebalance-frequency-input") as HTMLInputElement; +syncRebalanceFrequencyInput(); +rebalanceFrequencyInput.addEventListener("input", () => { + const value = Number(rebalanceFrequencyInput.value); + setRebalancesPerYear(value); + updatePreview(); + renderPosition(); + renderPortfolioSummary(); +}); ($("initial-input") as HTMLInputElement).addEventListener("input", () => { refreshTabData(); updatePreview(); }); ($("initial-input") as HTMLInputElement).addEventListener("change", () => { refreshTabData(); updatePreview(); }); @@ -2230,14 +2296,22 @@ async function refreshAddFundsBalance() { $("demo-btn").addEventListener("click", () => { demoMode = true; userAddress = "GDEMO000000000000000000000000000000000000000000000000000"; + syncRebalanceFrequencyInput(); showConnected(); $("wallet-address").textContent = "Demo Mode"; $("switch-wallet-btn").classList.add("hidden"); // Load mock reserves and positions - reserves = assets.map(a => ({ + reserves = assets.map(a => ({ asset: a, cFactor: a.cFactor, lFactor: 1, interestSupplyApr: 4.2, interestBorrowApr: 6.8, blndSupplyApr: 2.1, blndBorrowApr: 1.5, netSupplyApr: 6.3, netBorrowCost: 5.3, totalSupply: 1000000, totalBorrow: 650000, available: 350000, priceUsd: 1.0, + bRate: 1_000_000_000_000n, dRate: 1_000_000_000_000n, + bSupply: 10_000_000_000_000n, dSupply: 6_500_000_000_000n, + supplyEps: 0n, borrowEps: 0n, supplyEmission: null, borrowEmission: null, + rateConfig: { + rBase: 300_000, rOne: 400_000, rTwo: 1_200_000, rThree: 50_000_000, + utilOpt: 5_000_000, irMod: 1_000_000, backstopFP: selectedPool.backstopFP, + }, })); positions = { byAsset: new Map() }; // One sample position @@ -2372,11 +2446,13 @@ function renderOverview(blendPos: OverviewBlendPosition[], vaultPos: OverviewVau for (const bp of blendPos) { const rs = bp.reserves.find(r => r.asset.id === bp.asset.id); const price = rs?.priceUsd ?? 0; - const batchNetApr = rs ? rs.netSupplyApr * bp.pos.leverage - rs.netBorrowCost * (bp.pos.leverage - 1) : 0; + const batchRawNetApr = rs ? rs.netSupplyApr * bp.pos.leverage - rs.netBorrowCost * (bp.pos.leverage - 1) : 0; + const batchFee = rs ? feeAdjustedNetApr(batchRawNetApr, bp.pos.equity * price, bp.pos.leverage, bp.reserves) : { netApr: batchRawNetApr, feeDragApr: 0 }; + const batchNetApr = batchFee.netApr; const netApy = aprToApy(batchNetApr); const hfColor = bp.pos.hf > 1.1 ? "hf-ok" : bp.pos.hf > 1.03 ? "hf-warn" : "hf-bad"; const pool = getKnownPools().find(p => p.id === bp.pool.id)!; - const batchTip = `Approximate APY — Blend does not auto-compound. Actual net APR: ${fmt(batchNetApr, 2)}%`; + const batchTip = `Approximate APY - Blend does not auto-compound. ${feeDragTip(batchRawNetApr, batchFee.feeDragApr)}`; html += ` ${bp.asset.symbol} @@ -2522,12 +2598,13 @@ async function refreshVaultView() { // Net APY (stats.netApy is actually APR — convert for display) const apyEl = $("vault-apy"); if (stats.netApy !== null) { - const vaultApy = aprToApy(stats.netApy); + const vaultFee = feeAdjustedNetApr(stats.netApy, stats.totalEquity, stats.leverage, poolReserves ?? reserves); + const vaultApy = aprToApy(vaultFee.netApr); apyEl.textContent = (vaultApy >= 0 ? "+" : "") + vaultApy.toFixed(2) + "%"; apyEl.className = "stat-value mono " + (vaultApy > 0 ? "hf-ok" : "hf-bad"); const vaultTip = $("vault-apy-tip"); if (vaultTip) vaultTip.setAttribute("data-tip", - `Approximate APY — Blend interest does not auto-compound. Actual net APR: ${fmt(stats.netApy, 2)}%`); + `Approximate APY - Blend interest does not auto-compound. ${feeDragTip(stats.netApy, vaultFee.feeDragApr)}`); } else { apyEl.textContent = "--"; apyEl.className = "stat-value mono"; @@ -2730,6 +2807,7 @@ $("vault-rebalance-btn").addEventListener("click", async () => { const saved = localStorage.getItem("walletAddress"); if (!saved) return; userAddress = saved; + syncRebalanceFrequencyInput(); showConnected(); buildPoolTabs(); buildAssetTabs(); diff --git a/frontend/src/style.css b/frontend/src/style.css index 0d4348f..ede7987 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -639,6 +639,8 @@ main { flex: 1; max-width: 1200px; width: 100%; margin: 0 auto; padding: 20px 24 .preview-row strong { font-family: var(--mono); color: var(--text); font-weight: 600; } .preview-net-apr { border-top: 1px solid var(--border); margin-top: 4px; padding-top: 8px; } .prev-net-apr { font-size: 16px !important; font-weight: 700 !important; } +.fee-drag-control { display: flex; align-items: center; gap: 4px; justify-content: flex-end; color: var(--text-2); } +.fee-drag-input { width: 56px; padding: 3px 5px; font-size: 12px; text-align: right; } .hf-ok { color: var(--success) !important; } .hf-warn { color: var(--warning) !important; }