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 + 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,