From 9b8b6c4d09cc093c63625d1ee5a01c112b09c7f3 Mon Sep 17 00:00:00 2001
From: Sikkra <159844544+Sikkra@users.noreply.github.com>
Date: Tue, 19 May 2026 10:31:31 -0500
Subject: [PATCH] Add worst-case HF stress panel
---
frontend/index.html | 11 +++++++
frontend/src/main.ts | 68 ++++++++++++++++++++++++++++++++++++++++--
frontend/src/style.css | 14 +++++++++
frontend/tsconfig.json | 1 +
4 files changed, 92 insertions(+), 2 deletions(-)
diff --git a/frontend/index.html b/frontend/index.html
index f904f23..11fb0da 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -429,6 +429,17 @@
Open Position
Days to liquidation—
+
+
+ Worst-case 99% utilization--
+
+
+
30d HF--
+
Borrow APR--
+
Interest spread--
+
+
+
⚠ HF too low — liquidation risk. Reduce leverage.
diff --git a/frontend/src/main.ts b/frontend/src/main.ts
index fcc1ceb..9650aaa 100644
--- a/frontend/src/main.ts
+++ b/frontend/src/main.ts
@@ -749,6 +749,63 @@ function renderApyChart(rs: ReserveStats | undefined, currentLev: number, equity
`;
}
+const RATE_SCALAR = 10_000_000;
+const RATE_KINK_95 = 9_500_000;
+const WORST_CASE_UTIL = 0.99;
+const WORST_CASE_DAYS = 30;
+
+function stressRatesAtUtil(rs: ReserveStats, util: number) {
+ const { rBase, rOne, rTwo, rThree, utilOpt, irMod, backstopFP } = rs.rateConfig;
+ const utilFp = Math.round(Math.max(0, Math.min(1, util)) * RATE_SCALAR);
+
+ let baseRate: number;
+ if (utilFp <= utilOpt) {
+ baseRate = rBase + Math.ceil(rOne * utilFp / Math.max(1, utilOpt));
+ } else if (utilFp <= RATE_KINK_95) {
+ const slope = Math.ceil((utilFp - utilOpt) * RATE_SCALAR / Math.max(1, RATE_KINK_95 - utilOpt));
+ baseRate = rBase + rOne + Math.ceil(rTwo * slope / RATE_SCALAR);
+ } else {
+ const slope = Math.ceil((utilFp - RATE_KINK_95) * RATE_SCALAR / (RATE_SCALAR - RATE_KINK_95));
+ baseRate = rBase + rOne + rTwo + Math.ceil(rThree * slope / RATE_SCALAR);
+ }
+
+ const curIr = Math.ceil(baseRate * irMod / RATE_SCALAR);
+ const borrowApr = (curIr / RATE_SCALAR) * 100;
+ const supplyCapture = Math.floor((RATE_SCALAR - backstopFP) * utilFp / RATE_SCALAR);
+ const supplyApr = (Math.floor(curIr * supplyCapture / RATE_SCALAR) / RATE_SCALAR) * 100;
+ return { borrowApr, supplyApr, spreadApr: Math.max(0, borrowApr - supplyApr) };
+}
+
+function hfClass(hf: number) {
+ return hf > 1.1 ? "hf-ok" : hf > 1.03 ? "hf-warn" : "hf-bad";
+}
+
+function renderWorstCasePanel(rs: ReserveStats | undefined, hf: number, lev: number, equity: number, supply: number, borrow: number) {
+ const panel = $("worst-case-panel") as HTMLElement;
+ if (!rs || !rs.rateConfig || !isFinite(hf) || hf <= 0 || lev <= 1 || equity <= 0 || borrow <= 0) {
+ panel.classList.add("hidden");
+ return;
+ }
+
+ const { borrowApr, supplyApr, spreadApr } = stressRatesAtUtil(rs, WORST_CASE_UTIL);
+ const hf30 = hf * Math.exp(-(spreadApr / 100) * (WORST_CASE_DAYS / 365));
+ const hfDigits = expertMode ? 5 : 4;
+
+ const hfEl = $("worst-case-hf");
+ hfEl.textContent = `${fmt(hf, hfDigits)} -> ${fmt(hf30, hfDigits)}`;
+ hfEl.className = hfClass(hf30);
+ const hf30El = $("worst-case-hf-30d");
+ hf30El.textContent = fmt(hf30, hfDigits);
+ hf30El.className = hfClass(hf30);
+ $("worst-case-borrow").textContent = `${fmt(borrowApr, 2)}%`;
+ $("worst-case-spread").textContent = `${fmt(spreadApr, 2)}%`;
+ $("worst-case-copy").textContent =
+ `If r_three is active and utilization stays at 99% for ${WORST_CASE_DAYS} days, this ${fmt(lev, 1)}x position ` +
+ `supplies ${fmt(supply, 2)} ${rs.asset.symbol}, borrows ${fmt(borrow, 2)} ${rs.asset.symbol}, and HF moves from ` +
+ `${fmt(hf, hfDigits)} to ${fmt(hf30, hfDigits)}. Supply APR at stress is ${fmt(supplyApr, 2)}%.`;
+ panel.classList.remove("hidden");
+}
+
// ── Render reserve stats for selected asset ───────────────────────────────────
function renderSelectedAsset() {
@@ -1237,6 +1294,7 @@ function updatePreview() {
// APY chart (#14)
renderApyChart(rs, lev, equity, oldSupply, oldBorrow);
}
+ renderWorstCasePanel(rs, hf, lev, equity, supply, borrow);
// Risk zone labels (#9)
const maxSlider = parseFloat(($("leverage-slider") as HTMLInputElement).max) || 10;
@@ -1707,7 +1765,7 @@ function showConnected() {
async function connect() {
try {
- const result = await StellarWalletsKit.authModal({ network: getActiveNetwork() === "testnet" ? Networks.TESTNET : Networks.PUBLIC });
+ const result = await StellarWalletsKit.authModal();
// Verify wallet network matches app network
const networkOk = await verifyWalletNetwork();
if (!networkOk) {
@@ -1729,7 +1787,7 @@ async function connect() {
/** Re-open wallet modal to switch to a different account without a full page reload. */
async function switchWallet() {
try {
- const result = await StellarWalletsKit.authModal({ network: getActiveNetwork() === "testnet" ? Networks.TESTNET : Networks.PUBLIC });
+ const result = await StellarWalletsKit.authModal();
if (result.address === userAddress) return;
// Verify wallet network matches app network
const networkOk = await verifyWalletNetwork();
@@ -2238,6 +2296,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 * RATE_SCALAR), irMod: RATE_SCALAR, backstopFP: selectedPool.backstopFP,
+ },
}));
positions = { byAsset: new Map() };
// One sample position
diff --git a/frontend/src/style.css b/frontend/src/style.css
index 0d4348f..7df3c1c 100644
--- a/frontend/src/style.css
+++ b/frontend/src/style.css
@@ -640,6 +640,19 @@ 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; }
+.worst-case-panel {
+ margin-top: 10px; padding: 12px 14px; border: 1px solid var(--warning-border);
+ border-radius: var(--r); background: var(--warning-bg); display: flex; flex-direction: column; gap: 8px;
+}
+.worst-case-head { display: flex; justify-content: space-between; gap: 12px; align-items: baseline; }
+.worst-case-head span { color: var(--text); font-size: 13px; font-weight: 600; }
+.worst-case-head strong { flex-shrink: 0; font-family: var(--mono); font-size: 14px; text-align: right; white-space: nowrap; }
+.worst-case-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px 12px; }
+.worst-case-grid div { min-width: 0; }
+.worst-case-grid span { display: block; color: var(--text-2); font-size: 11px; }
+.worst-case-grid strong { display: block; margin-top: 2px; color: var(--text); font-family: var(--mono); font-size: 13px; white-space: nowrap; }
+.worst-case-panel p { margin: 0; color: var(--text-2); font-size: 12px; line-height: 1.45; }
+
.hf-ok { color: var(--success) !important; }
.hf-warn { color: var(--warning) !important; }
.hf-bad { color: var(--danger) !important; }
@@ -1198,6 +1211,7 @@ main { flex: 1; max-width: 1200px; width: 100%; margin: 0 auto; padding: 20px 24
.slippage-custom input { width: 60px; font-size: 11px; }
.apy-chart svg { height: 80px; }
.apy-chart-label { font-size: 7px; }
+ .worst-case-grid { grid-template-columns: 1fr; }
.disclaimer-modal { padding: 20px 16px; max-width: 100%; }
.disclaimer-modal h2 { font-size: 18px; }
.input { padding-right: 80px; }
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
index 1616678..2e6e2a7 100644
--- a/frontend/tsconfig.json
+++ b/frontend/tsconfig.json
@@ -4,6 +4,7 @@
"lib": ["ES2020", "DOM"],
"module": "ESNext",
"moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,