From 1ae3e872e3e689168702599cdcf6e9a13e64696b Mon Sep 17 00:00:00 2001 From: Sikkra <159844544+Sikkra@users.noreply.github.com> Date: Tue, 19 May 2026 11:02:22 -0500 Subject: [PATCH] Add APY utilization stress panel --- frontend/index.html | 12 ++++++ frontend/src/main.ts | 93 ++++++++++++++++++++++++++++++++++++++++++ frontend/src/style.css | 44 ++++++++++++++++++++ 3 files changed, 149 insertions(+) diff --git a/frontend/index.html b/frontend/index.html index f904f23..8be7415 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -429,6 +429,18 @@

Open Position

Days to liquidation +
+
+ Utilization stress test ? + +
+
+ + + 97% +
+
+
diff --git a/frontend/src/main.ts b/frontend/src/main.ts index fcc1ceb..aa6d2a1 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -335,6 +335,8 @@ 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 STRESS_PRESET_UTILS = [0.80, 0.90, 0.95, 0.99]; +const STRESS_HF_DAYS = 30; // ── Skeleton loading (#3) ──────────────────────────────────────────────────── @@ -749,6 +751,87 @@ function renderApyChart(rs: ReserveStats | undefined, currentLev: number, equity `; } +// ── Utilization stress panel ────────────────────────────────────────────────── + +function formatStressDays(hf: number, spreadPct: number): { text: string; cls: string } { + if (!isFinite(hf)) return { text: "Never", cls: "hf-ok" }; + if (hf <= 1) return { text: "Now", cls: "hf-bad" }; + if (spreadPct <= 0) return { text: "Never", cls: "hf-ok" }; + + const days = Math.log(hf) / (spreadPct / 100) * 365; + if (!isFinite(days) || days > 3650) return { text: ">10 years", cls: "hf-ok" }; + return { + text: `~${Math.max(0, Math.round(days))} days`, + cls: days < 30 ? "hf-bad" : days < 90 ? "hf-warn" : "hf-ok", + }; +} + +function stressHfAfterDays(hf: number, spreadPct: number, days: number): number { + if (!isFinite(hf)) return Infinity; + return hf * Math.exp(-(spreadPct / 100) * (days / 365)); +} + +function renderStressPanel( + rs: ReserveStats | undefined, + lev: number, + equity: number, + oldSupply: number, + oldBorrow: number, + hf: number, +) { + const scenariosEl = $("stress-scenarios"); + const currentUtilEl = $("stress-current-util"); + const customSlider = $("stress-util-slider") as HTMLInputElement; + const customLabel = $("stress-custom-label"); + const customUtil = Math.max(0.5, Math.min(0.99, Number(customSlider.value) / 100)); + customLabel.textContent = `${Math.round(customUtil * 100)}%`; + + if (!rs) { + currentUtilEl.textContent = "No pool data"; + scenariosEl.innerHTML = `
Load reserve data to run utilization stress scenarios.
`; + return; + } + + const currentUtil = rs.totalSupply > 0 ? rs.totalBorrow / rs.totalSupply : 0; + currentUtilEl.textContent = `Current ${fmt(currentUtil * 100, 1)}%`; + + const addSupply = equity * lev - oldSupply; + const baseSupply = rs.totalSupply + addSupply; + if (baseSupply <= 0) { + scenariosEl.innerHTML = `
Pool supply is unavailable for stress testing.
`; + return; + } + + const scenarios = [ + ...STRESS_PRESET_UTILS.map(util => ({ label: `${Math.round(util * 100)}%`, util, custom: false })), + { label: `${Math.round(customUtil * 100)}%`, util: customUtil, custom: true }, + ]; + + scenariosEl.innerHTML = scenarios.map(s => { + const stressedBorrow = Math.max(0, baseSupply * s.util); + const stressed = projectRates(rs, addSupply, stressedBorrow - rs.totalBorrow); + const netApr = stressed.netSupplyApr * lev - stressed.netBorrowCost * (lev - 1); + const netApy = aprToApy(netApr); + const spreadPct = stressed.interestBorrowApr - stressed.interestSupplyApr; + const hf30 = stressHfAfterDays(hf, spreadPct, STRESS_HF_DAYS); + const ttl = formatStressDays(hf, spreadPct); + const kink = s.util >= 0.95; + const hfCls = !isFinite(hf30) || hf30 > 1.1 ? "hf-ok" : hf30 > 1.03 ? "hf-warn" : "hf-bad"; + + return `
+
+ ${s.label} + ${s.custom ? "custom" : kink ? "r_three kink" : "preset"} +
+
+ Net APY${netApy >= 0 ? "+" : ""}${fmt(netApy, 2)}% + ${STRESS_HF_DAYS}d HF${isFinite(hf30) ? fmt(hf30, 3) : "∞"} + Liquidation${ttl.text} +
+
`; + }).join(""); +} + // ── Render reserve stats for selected asset ─────────────────────────────────── function renderSelectedAsset() { @@ -1236,6 +1319,9 @@ function updatePreview() { // APY chart (#14) renderApyChart(rs, lev, equity, oldSupply, oldBorrow); + renderStressPanel(rs, lev, equity, oldSupply, oldBorrow, hf); + } else { + renderStressPanel(undefined, lev, equity, oldSupply, oldBorrow, hf); } // Risk zone labels (#9) @@ -2201,6 +2287,7 @@ async function refreshAddFundsBalance() { } ($("leverage-slider") as HTMLInputElement).addEventListener("input", updatePreview); +($("stress-util-slider") as HTMLInputElement).addEventListener("input", updatePreview); // Live preview while typing (no clamping so user can type multi-digit numbers like "10") ($("leverage-input") as HTMLInputElement).addEventListener("input", () => { const numIn = $("leverage-input") as HTMLInputElement; @@ -2238,6 +2325,12 @@ $("demo-btn").addEventListener("click", () => { 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: 1_000_000_000_000n, dSupply: 650_000_000_000n, + supplyEps: 0n, borrowEps: 0n, supplyEmission: null, borrowEmission: null, + rateConfig: { + rBase: 0, rOne: 500_000, rTwo: 2_000_000, rThree: 150_000_000, + utilOpt: Math.round(a.maxUtil * 10_000_000), irMod: 10_000_000, backstopFP: selectedPool.backstopFP, + }, })); positions = { byAsset: new Map() }; // One sample position diff --git a/frontend/src/style.css b/frontend/src/style.css index 0d4348f..6b04562 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -640,6 +640,50 @@ main { flex: 1; max-width: 1200px; width: 100%; margin: 0 auto; padding: 20px 24 .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; } +.stress-panel { + margin-top: 10px; background: var(--metric-bg); border: 1px solid var(--border); + border-radius: var(--r); padding: 12px 14px; display: flex; flex-direction: column; gap: 10px; +} +.stress-panel-head { + display: flex; justify-content: space-between; align-items: center; gap: 10px; + font-size: 12px; color: var(--text-2); font-weight: 700; +} +.stress-panel-head strong { color: var(--text); font-weight: 600; white-space: nowrap; } +.stress-custom-row { + display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 8px; + font-size: 12px; color: var(--text-3); +} +.stress-custom-row label { color: var(--text-2); font-weight: 600; } +.stress-slider { + width: 100%; height: 6px; border-radius: 999px; appearance: none; background: var(--slider-bg); +} +.stress-slider::-webkit-slider-thumb { + appearance: none; width: 14px; height: 14px; border-radius: 50%; + background: var(--primary); box-shadow: 0 0 0 3px var(--slider-glow); cursor: pointer; +} +.stress-scenarios { display: grid; gap: 8px; } +.stress-scenario { + border: 1px solid var(--border); border-radius: var(--r-xs); padding: 9px 10px; + background: rgba(255,255,255,.02); +} +.stress-scenario-kink { border-color: var(--danger-border); background: var(--danger-bg); } +.stress-scenario-top { + display: flex; justify-content: space-between; align-items: center; gap: 8px; margin-bottom: 7px; +} +.stress-util { color: var(--text); font-weight: 800; font-size: 13px; } +.stress-badge { + color: var(--text-3); border: 1px solid var(--border); border-radius: var(--r-xs); + padding: 2px 6px; font-size: 10px; text-transform: uppercase; letter-spacing: .3px; +} +.stress-scenario-kink .stress-badge { color: var(--danger); border-color: var(--danger-border); } +.stress-metrics { + display: grid; grid-template-columns: auto minmax(72px, auto); gap: 4px 10px; + align-items: center; font-size: 12px; +} +.stress-metrics span { color: var(--text-3); } +.stress-metrics strong { text-align: right; font-family: var(--mono); font-weight: 700; } +.stress-empty { color: var(--text-3); font-size: 12px; } + .hf-ok { color: var(--success) !important; } .hf-warn { color: var(--warning) !important; } .hf-bad { color: var(--danger) !important; }