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%
+
+
+
⚠ HF too low — liquidation risk. Reduce leverage.
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; }